diff --git a/.drone.star b/.drone.star index 171a65a15b220..0829f79f299eb 100644 --- a/.drone.star +++ b/.drone.star @@ -6,7 +6,8 @@ load('scripts/drone/pipelines/pr.star', 'pr_pipelines') load('scripts/drone/pipelines/main.star', 'main_pipelines') -load('scripts/drone/pipelines/release.star', 'release_pipelines', 'test_release_pipelines') +load('scripts/drone/pipelines/docs.star', 'docs_pipelines') +load('scripts/drone/pipelines/release.star', 'release_pipelines', 'publish_image_pipelines', 'publish_artifacts_pipelines', 'publish_npm_pipelines', 'publish_packages_pipeline') load('scripts/drone/version.star', 'version_branch_pipelines') load('scripts/drone/pipelines/cron.star', 'cronjobs') load('scripts/drone/vault.star', 'secrets') @@ -14,4 +15,7 @@ load('scripts/drone/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() + cronjobs(edition=edition) + secrets() + publish_image_pipelines('public') + publish_image_pipelines('security') + \ + publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \ + publish_npm_pipelines('public') + publish_packages_pipeline() + \ + version_branch_pipelines() + cronjobs(edition=edition) + secrets() diff --git a/.drone.yml b/.drone.yml index 47f020047d84d..252ae0dc4cfa4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,19 +11,19 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize - commands: - ./bin/grabpl verify-drone @@ -43,13 +43,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss @@ -57,7 +57,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-backend - commands: - yarn run prettier:check @@ -67,19 +67,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -87,12 +87,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-frontend trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- depends_on: [] kind: pipeline @@ -106,47 +113,55 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --variants linux-x64,linux-x64-musl,osx64,win64,armv6 --no-pull-enterprise depends_on: - initialize environment: {} - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize - image: grafana/build-container:1.4.5 + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 name: build-frontend +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps depends_on: - initialize environment: null - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -167,92 +182,120 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: ensure-cuetsified - 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,armv6 + --build-id ${DRONE_BUILD_NUMBER} depends_on: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: null - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package - commands: - - ./e2e/start-server + - ./scripts/grafana-server/start-server depends_on: - - package + - build-plugins + - build-backend + - build-frontend + - build-frontend-packages detach: true environment: PORT: 3001 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + image: grafana/build-container:1.5.3 + name: grafana-server +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite dashboards-suite + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-dashboards-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite smoke-tests-suite + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-smoke-tests-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite panels-suite + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-panels-suite - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite various-suite depends_on: - - end-to-end-tests-server + - package environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-various-suite - commands: - yarn storybook:build - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-storybook - commands: - yarn wait-on http://$HOST:$PORT - pa11y-ci --config .pa11yci-pr.conf.js depends_on: - - end-to-end-tests-server + - grafana-server environment: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key - HOST: end-to-end-tests-server + HOST: grafana-server PORT: 3001 failure: always image: hugohaggmark/docker-puppeteer name: test-a11y-frontend -- commands: - - ./scripts/ci-reference-docs-lint.sh ci - depends_on: - - build-frontend - image: grafana/build-container:1.4.5 - name: build-frontend-docs -- commands: - - mkdir -p /hugo/content/docs/grafana - - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ - - cd /hugo && make prod - depends_on: - - build-frontend-docs - image: grafana/docs-base:latest - name: build-docs-website - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: copy-packages-for-docker -- depends_on: +- commands: + - ./bin/grabpl build-docker --edition oss -archs amd64 + depends_on: - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk name: build-docker-images - settings: - archs: amd64 - dry_run: true - edition: oss - ubuntu: false + volumes: + - name: docker + path: /var/run/docker.sock trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- depends_on: [] kind: pipeline @@ -264,11 +307,15 @@ platform: os: linux services: - environment: + PGDATA: /var/lib/postgresql/data/pgdata POSTGRES_DB: grafanatest POSTGRES_PASSWORD: grafanatest POSTGRES_USER: grafanatest image: postgres:12.3-alpine name: postgres + volumes: + - name: postgres + path: /var/lib/postgresql/data/pgdata - environment: MYSQL_DATABASE: grafana_tests MYSQL_PASSWORD: password @@ -276,13 +323,26 @@ services: MYSQL_USER: grafana image: mysql:5.6.48 name: mysql + volumes: + - name: mysql + path: /var/lib/mysql steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize - commands: - apt-get update - apt-get install -yq postgresql-client @@ -296,7 +356,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: postgres-integration-tests - commands: - apt-get update @@ -311,51 +371,214 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: mysql-integration-tests trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- depends_on: [] kind: pipeline -name: build-main +name: pr-docs node: type: no-parallel platform: arch: amd64 os: linux -services: -- environment: - POSTGRES_DB: grafanatest - POSTGRES_PASSWORD: grafanatest - POSTGRES_USER: grafanatest - image: postgres:12.3-alpine - name: postgres -- environment: - MYSQL_DATABASE: grafana_tests - MYSQL_PASSWORD: password - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_USER: grafana - image: mysql:5.6.48 - name: mysql +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize +- commands: + - |- + echo -e "unknwon + referer + errorstring + eror + iam + wan" > words_to_ignore.txt + - codespell -I words_to_ignore.txt docs/ + - rm words_to_ignore.txt + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: codespell +- commands: + - yarn run prettier:checkDocs + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: lint-docs +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages +- commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend-packages + image: grafana/build-container:1.5.3 + name: build-frontend-docs +- commands: + - mkdir -p /hugo/content/docs/grafana + - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ + - cd /hugo && make prod + depends_on: + - build-frontend-docs + image: grafana/docs-base:latest + name: build-docs-website +trigger: + event: + - pull_request + paths: + include: + - docs/** + - packages/** +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: main-docs +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize +- commands: + - |- + echo -e "unknwon + referer + errorstring + eror + iam + wan" > words_to_ignore.txt + - codespell -I words_to_ignore.txt docs/ + - rm words_to_ignore.txt + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: codespell +- commands: + - yarn run prettier:checkDocs + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: lint-docs +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages +- commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend-packages + image: grafana/build-container:1.5.3 + name: build-frontend-docs +- commands: + - mkdir -p /hugo/content/docs/grafana + - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ + - cd /hugo && make prod + depends_on: + - build-frontend-docs + image: grafana/docs-base:latest + name: build-docs-website +trigger: + branch: main + event: + - push +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: main-test +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize - commands: - ./bin/grabpl verify-drone @@ -363,17 +586,6 @@ steps: - grabpl image: byrnedo/alpine-curl:0.1.8 name: lint-drone -- image: grafana/drone-downstream - name: trigger-enterprise-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 - commands: - |- echo -e "unknwon @@ -386,13 +598,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition oss @@ -400,7 +612,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-backend - commands: - yarn run prettier:check @@ -410,19 +622,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-frontend - commands: - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend - commands: - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -430,53 +642,78 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-frontend +trigger: + branch: main + event: + - push +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: main-build-e2e-publish +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: - commands: - - apt-get update - - apt-get install -yq postgresql-client - - 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 + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize +- image: grafana/drone-downstream + name: trigger-enterprise-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 +- commands: + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - - grabpl + - initialize + environment: {} + image: grafana/build-container:1.5.3 + name: build-backend +- commands: + - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} + depends_on: + - initialize environment: - GRAFANA_TEST_DB: postgres - PGPASSWORD: grafanatest - POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 - name: postgres-integration-tests + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend - commands: - - apt-get update - - apt-get install -yq default-mysql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: mysql - MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 - name: mysql-integration-tests -- commands: - - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise - depends_on: - - initialize - environment: {} - image: grafana/build-container:1.4.5 - name: build-backend -- commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize - image: grafana/build-container:1.4.5 - name: build-frontend + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin depends_on: @@ -484,13 +721,13 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -511,15 +748,15 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise - --sign + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign depends_on: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: GITHUB_TOKEN: from_secret: github_token @@ -531,56 +768,92 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package - commands: - - ./e2e/start-server + - ./scripts/grafana-server/start-server depends_on: - - package + - build-plugins + - build-backend + - build-frontend + - build-frontend-packages detach: true environment: PORT: 3001 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + image: grafana/build-container:1.5.3 + name: grafana-server +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite dashboards-suite + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-dashboards-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite smoke-tests-suite + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-smoke-tests-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite panels-suite + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-panels-suite - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite various-suite depends_on: - - end-to-end-tests-server + - package environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-various-suite - commands: - yarn storybook:build - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-storybook - 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 + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${PRERELEASE_BUCKET}/artifacts/storybook/canary depends_on: - build-storybook - - end-to-end-tests + - end-to-end-tests-dashboards-suite + - end-to-end-tests-panels-suite + - end-to-end-tests-smoke-tests-suite + - end-to-end-tests-various-suite environment: GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 - name: publish-storybook + name: store-storybook - commands: - yarn wait-on http://$HOST:$PORT - pa11y-ci --config .pa11yci.conf.js --json > pa11y-ci-results.json depends_on: - - end-to-end-tests-server + - grafana-server environment: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key - HOST: end-to-end-tests-server + HOST: grafana-server PORT: 3001 failure: ignore image: hugohaggmark/docker-puppeteer @@ -593,70 +866,110 @@ steps: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key failure: ignore - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: publish-frontend-metrics -- commands: - - ./scripts/ci-reference-docs-lint.sh ci - depends_on: - - build-frontend - image: grafana/build-container:1.4.5 - name: build-frontend-docs - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: copy-packages-for-docker -- depends_on: +- commands: + - ./bin/grabpl build-docker --edition oss + depends_on: - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk name: build-docker-images - settings: - dry_run: false - edition: oss - password: - from_secret: docker_password - ubuntu: false - username: - from_secret: docker_user -- depends_on: + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl build-docker --edition oss --ubuntu + depends_on: - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk name: build-docker-images-ubuntu - settings: - dry_run: false - edition: oss - password: + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana --base alpine --base + ubuntu --arch amd64 --arch arm64 --arch armv7 + depends_on: + - build-docker-images + - build-docker-images-ubuntu + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana-oss --base alpine + --base ubuntu --arch amd64 --arch arm64 --arch armv7 + depends_on: + - build-docker-images + - build-docker-images-ubuntu + environment: + DOCKER_PASSWORD: from_secret: docker_password - ubuntu: true - username: - from_secret: docker_user + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana-oss + volumes: + - name: docker + path: /var/run/docker.sock - commands: - ./scripts/circle-release-canary-packages.sh depends_on: - - end-to-end-tests + - end-to-end-tests-dashboards-suite + - end-to-end-tests-panels-suite + - end-to-end-tests-smoke-tests-suite + - end-to-end-tests-various-suite environment: GITHUB_PACKAGE_TOKEN: from_secret: github_package_token - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: release-canary-npm-packages - commands: - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads depends_on: - - end-to-end-tests + - end-to-end-tests-dashboards-suite + - end-to-end-tests-panels-suite + - end-to-end-tests-smoke-tests-suite + - end-to-end-tests-various-suite environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages - commands: - - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + - ./bin/grabpl upload-cdn --edition oss --src-bucket "grafana-static-assets" depends_on: - - end-to-end-tests-server + - grafana-server environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets trigger: @@ -664,9 +977,113 @@ trigger: event: - push type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- +depends_on: [] +kind: pipeline +name: main-integration-tests +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: +- environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + image: postgres:12.3-alpine + name: postgres + volumes: + - name: postgres + path: /var/lib/postgresql/data/pgdata +- environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + image: mysql:5.6.48 + name: mysql + volumes: + - name: mysql + path: /var/lib/mysql +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize +- commands: + - apt-get update + - apt-get install -yq postgresql-client + - 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 + depends_on: + - grabpl + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + image: grafana/build-container:1.5.3 + name: postgres-integration-tests +- commands: + - apt-get update + - apt-get install -yq default-mysql-client + - 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 + depends_on: + - grabpl + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + image: grafana/build-container:1.5.3 + name: mysql-integration-tests +trigger: + branch: main + event: + - push +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- depends_on: -- build-main +- main-test +- main-build-e2e-publish +- main-integration-tests kind: pipeline name: windows-main platform: @@ -681,7 +1098,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -693,7 +1110,9 @@ steps: - 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 + - .\grabpl.exe gen-version --build-id $$env:DRONE_BUILD_NUMBER + - .\grabpl.exe windows-installer --edition oss --packages-bucket grafana-downloads + --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/ @@ -702,6 +1121,10 @@ steps: environment: GCP_KEY: from_secret: gcp_key + GITHUB_TOKEN: + from_secret: github_token + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/ci-wix:0.1.1 name: build-windows-installer trigger: @@ -709,9 +1132,15 @@ trigger: event: - push type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- depends_on: -- build-main +- main-test +- main-build-e2e-publish +- main-integration-tests - windows-main kind: pipeline name: publish-main @@ -724,24 +1153,23 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize - commands: - - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id + - ./bin/grabpl store-packages --edition oss --gcp-key /tmp/gcpkey.json --build-id ${DRONE_BUILD_NUMBER} depends_on: - - initialize + - grabpl environment: GCP_KEY: from_secret: gcp_key @@ -754,15 +1182,21 @@ steps: GRAFANA_COM_API_KEY: from_secret: grafana_api_key image: grafana/grafana-ci-deploy:1.3.1 - name: publish-packages-oss + name: store-packages-oss trigger: branch: main event: - push type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- depends_on: -- build-main +- main-test +- main-build-e2e-publish +- main-integration-tests - windows-main - publish-main kind: pipeline @@ -791,155 +1225,59 @@ type: docker --- depends_on: [] kind: pipeline -name: oss-build-release +name: oss-build-publish-e2e-release node: type: no-parallel platform: arch: amd64 os: linux -services: -- environment: - POSTGRES_DB: grafanatest - POSTGRES_PASSWORD: grafanatest - POSTGRES_USER: grafanatest - image: postgres:12.3-alpine - name: postgres -- environment: - MYSQL_DATABASE: grafana_tests - MYSQL_PASSWORD: password - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_USER: grafana - image: mysql:5.6.48 - name: mysql +services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - ./bin/grabpl verify-version ${DRONE_TAG} - ./bin/grabpl gen-version ${DRONE_TAG} - yarn install --immutable - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize -- commands: - - ./bin/grabpl verify-drone - depends_on: - - grabpl - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone -- commands: - - |- - echo -e "unknwon - referer - errorstring - eror - iam - wan" > words_to_ignore.txt - - codespell -I words_to_ignore.txt docs/ - - rm words_to_ignore.txt - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: shellcheck -- commands: - - ./bin/grabpl lint-backend --edition oss - depends_on: - - initialize - environment: - CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 - name: lint-backend -- commands: - - yarn run prettier:check - - yarn run lint - - yarn run typecheck - depends_on: - - initialize - environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: lint-frontend -- commands: - - ./bin/grabpl test-backend --edition oss - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend -- commands: - - ./bin/grabpl integration-tests --edition oss - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration -- commands: - - yarn run ci:test-frontend - depends_on: - - initialize - environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: test-frontend -- commands: - - apt-get update - - apt-get install -yq postgresql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: postgres - PGPASSWORD: grafanatest - POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 - name: postgres-integration-tests -- commands: - - apt-get update - - apt-get install -yq default-mysql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: mysql - MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 - name: mysql-integration-tests - commands: - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} - --no-pull-enterprise ${DRONE_TAG} + ${DRONE_TAG} depends_on: - initialize environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps - --edition oss --no-pull-enterprise ${DRONE_TAG} + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition + oss ${DRONE_TAG} depends_on: - initialize - image: grafana/build-container:1.4.5 + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 name: build-frontend +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} + --edition oss ${DRONE_TAG} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages - commands: - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin depends_on: @@ -947,13 +1285,13 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -974,15 +1312,16 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise - --sign ${DRONE_TAG} + - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --sign + ${DRONE_TAG} depends_on: - build-plugins - build-backend - build-frontend + - build-frontend-packages environment: GITHUB_TOKEN: from_secret: github_token @@ -994,237 +1333,206 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package - commands: - - ./e2e/start-server + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - detach: true - environment: - PORT: 3001 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + image: grafana/build-container:1.5.3 + name: copy-packages-for-docker - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 --tries 3 + - ./bin/grabpl build-docker --edition oss --shouldSave depends_on: - - end-to-end-tests-server + - copy-packages-for-docker environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock - commands: - - ls dist/*.tar.gz* - - cp dist/*.tar.gz* packaging/docker/ + - ./bin/grabpl build-docker --edition oss --shouldSave --ubuntu depends_on: - - package - image: grafana/build-container:1.4.5 - name: copy-packages-for-docker -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images - settings: - dry_run: false - edition: oss - password: - from_secret: docker_password - ubuntu: false - username: - from_secret: docker_user -- depends_on: - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk name: build-docker-images-ubuntu - settings: - dry_run: false - edition: oss - password: - from_secret: docker_password - ubuntu: true - username: - from_secret: docker_user + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./scripts/grafana-server/start-server + depends_on: + - build-plugins + - build-backend + - build-frontend + - build-frontend-packages + detach: true + environment: + PORT: 3001 + image: grafana/build-container:1.5.3 + name: grafana-server +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite dashboards-suite --tries 3 + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-dashboards-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite smoke-tests-suite --tries 3 + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-smoke-tests-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite panels-suite --tries 3 + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-panels-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite various-suite --tries 3 + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-various-suite - commands: - yarn storybook:build - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-storybook - commands: - - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + - ./bin/grabpl upload-cdn --edition oss --src-bucket "$${PRERELEASE_BUCKET}" --src-dir + artifacts/static-assets depends_on: - - end-to-end-tests-server + - grafana-server environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets - commands: - - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + - ./bin/grabpl upload-packages --edition oss --packages-bucket $${PRERELEASE_BUCKET}/artifacts/downloads depends_on: - - end-to-end-tests + - end-to-end-tests-dashboards-suite + - end-to-end-tests-panels-suite + - end-to-end-tests-smoke-tests-suite + - end-to-end-tests-various-suite environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages - 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} + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${PRERELEASE_BUCKET}/artifacts/storybook/latest + - gsutil -m rsync -d -r ./packages/grafana-ui/dist/storybook gs://$${PRERELEASE_BUCKET}/artifacts/storybook/${DRONE_TAG} depends_on: - build-storybook - - end-to-end-tests + - end-to-end-tests-dashboards-suite + - end-to-end-tests-panels-suite + - end-to-end-tests-smoke-tests-suite + - end-to-end-tests-various-suite environment: GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 - name: publish-storybook + name: store-storybook - commands: - - ./scripts/build/release-packages.sh ${DRONE_TAG} + - ./scripts/build/build-npm-packages.sh ${DRONE_TAG} depends_on: - - publish-storybook - environment: - GITHUB_PACKAGE_TOKEN: - from_secret: github_package_token - NPM_TOKEN: - from_secret: npm_token - image: grafana/build-container:1.4.5 - name: release-npm-packages -trigger: - ref: - - refs/tags/v* -type: docker ---- -depends_on: -- oss-build-release -kind: pipeline -name: oss-windows-release -platform: - arch: amd64 - os: windows - version: "1809" -services: [] -steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl -- commands: - - echo $env:DRONE_RUNNER_NAME - image: mcr.microsoft.com/windows:1809 - name: identify-runner -- commands: - - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/windows/grabpl.exe - -OutFile grabpl.exe - image: grafana/ci-wix:0.1.1 - name: initialize + - store-storybook + image: grafana/build-container:1.5.3 + name: build-npm-packages - 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/ + - ./bin/grabpl artifacts npm store --tag ${DRONE_TAG} depends_on: - - initialize + - build-npm-packages environment: GCP_KEY: from_secret: gcp_key - image: grafana/ci-wix:0.1.1 - name: build-windows-installer + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.1 + name: store-npm-packages trigger: + event: + exclude: + - promote ref: - refs/tags/v* + repo: + exclude: + - grafana/grafana type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- -clone: - disable: true depends_on: [] -image_pull_secrets: -- dockerconfigjson kind: pipeline -name: enterprise-build-release +name: oss-test-release node: type: no-parallel platform: arch: amd64 os: linux -services: -- environment: - POSTGRES_DB: grafanatest - POSTGRES_PASSWORD: grafanatest - POSTGRES_USER: grafanatest - image: postgres:12.3-alpine - name: postgres -- environment: - MYSQL_DATABASE: grafana_tests - MYSQL_PASSWORD: password - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_USER: grafana - image: mysql:5.6.48 - name: mysql -- environment: {} - image: redis:6.2.1-alpine - name: redis -- environment: {} - image: memcached:1.6.9-alpine - name: memcached +services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - - 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 - image: grafana/build-container:1.4.5 - name: clone -- commands: - - mv bin/grabpl /tmp/ - - rmdir bin - - mv grafana-enterprise /tmp/ - - /tmp/grabpl init-enterprise /tmp/grafana-enterprise ${DRONE_TAG} - - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - - mkdir bin - - mv /tmp/grabpl bin/ - make gen-go - ./bin/grabpl verify-version ${DRONE_TAG} - ./bin/grabpl gen-version ${DRONE_TAG} - yarn install --immutable - depends_on: - - grabpl - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize -- commands: - - ./bin/grabpl verify-drone - depends_on: - - grabpl - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone - commands: - |- echo -e "unknwon @@ -1237,21 +1545,21 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: shellcheck - commands: - - ./bin/grabpl lint-backend --edition enterprise + - ./bin/grabpl lint-backend --edition oss depends_on: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-backend - commands: - yarn run prettier:check @@ -1261,19 +1569,19 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-frontend - commands: - - ./bin/grabpl test-backend --edition enterprise + - ./bin/grabpl test-backend --edition oss depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend - commands: - - ./bin/grabpl integration-tests --edition enterprise + - ./bin/grabpl integration-tests --edition oss depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend-integration - commands: - yarn run ci:test-frontend @@ -1281,8 +1589,70 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-frontend +trigger: + event: + exclude: + - promote + ref: + - refs/tags/v* + repo: + exclude: + - grafana/grafana +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: oss-integration-tests-release +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: +- environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + image: postgres:12.3-alpine + name: postgres + volumes: + - name: postgres + path: /var/lib/postgresql/data/pgdata +- environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + image: mysql:5.6.48 + name: mysql + volumes: + - name: mysql + path: /var/lib/mysql +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl verify-version ${DRONE_TAG} + - ./bin/grabpl gen-version ${DRONE_TAG} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize - commands: - apt-get update - apt-get install -yq postgresql-client @@ -1296,7 +1666,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: postgres-integration-tests - commands: - apt-get update @@ -1311,40 +1681,186 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: mysql-integration-tests +trigger: + event: + exclude: + - promote + ref: + - refs/tags/v* + repo: + exclude: + - grafana/grafana +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- +depends_on: +- oss-build-publish-e2e-release +- oss-test-release +- oss-integration-tests-release +kind: pipeline +name: oss-windows-release +platform: + arch: amd64 + os: windows + version: "1809" +services: [] +steps: - commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --no-pull-enterprise ${DRONE_TAG} + - echo $env:DRONE_RUNNER_NAME + image: mcr.microsoft.com/windows:1809 + name: identify-runner +- commands: + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/windows/grabpl.exe + -OutFile grabpl.exe + image: grafana/ci-wix:0.1.1 + name: initialize +- 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 gen-version ${DRONE_TAG} + - .\grabpl.exe windows-installer --edition oss ${DRONE_TAG} + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/oss/release/ + - gsutil cp "$$fname.sha256" gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/oss/release/ depends_on: - initialize environment: + GCP_KEY: + from_secret: gcp_key GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.5 - name: build-backend + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/ci-wix:0.1.1 + name: build-windows-installer +trigger: + event: + exclude: + - promote + ref: + - refs/tags/v* + repo: + exclude: + - grafana/grafana +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +clone: + disable: true +depends_on: [] +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: enterprise-build-e2e-publish-release +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: - commands: - - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --no-install-deps - --edition enterprise --no-pull-enterprise ${DRONE_TAG} - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: build-frontend + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign - --signing-admin - depends_on: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - 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 + image: grafana/build-container:1.5.3 + name: clone-enterprise +- commands: + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise + ${DRONE_TAG} + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + - make gen-go + - ./bin/grabpl verify-version ${DRONE_TAG} + - ./bin/grabpl gen-version ${DRONE_TAG} + - yarn install --immutable + depends_on: + - clone-enterprise + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.5.3 + name: initialize +- commands: + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} + ${DRONE_TAG} + depends_on: + - initialize + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.5.3 + name: build-backend +- commands: + - ./bin/grabpl build-frontend --jobs 8 --github-token $${GITHUB_TOKEN} --edition + enterprise ${DRONE_TAG} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend +- commands: + - ./bin/grabpl build-frontend-packages --jobs 8 --github-token $${GITHUB_TOKEN} + --edition enterprise ${DRONE_TAG} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages +- commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign + --signing-admin + depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -1365,47 +1881,27 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: ensure-cuetsified -- commands: - - ./bin/grabpl lint-backend --edition enterprise2 - depends_on: - - initialize - environment: - CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 - name: lint-backend-enterprise2 -- commands: - - ./bin/grabpl test-backend --edition enterprise2 - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-enterprise2 -- commands: - - ./bin/grabpl integration-tests --edition enterprise2 - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration-enterprise2 - commands: - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --no-pull-enterprise ${DRONE_TAG} + ${DRONE_TAG} depends_on: - initialize environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-backend-enterprise2 - commands: - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --no-pull-enterprise --sign ${DRONE_TAG} + --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend - build-frontend + - build-frontend-packages - build-backend-enterprise2 - - test-backend-enterprise2 environment: GITHUB_TOKEN: from_secret: github_token @@ -1417,106 +1913,121 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package - commands: - - ./e2e/start-server + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ depends_on: - package + image: grafana/build-container:1.5.3 + name: copy-packages-for-docker +- commands: + - ./bin/grabpl build-docker --edition enterprise --shouldSave + depends_on: + - copy-packages-for-docker + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl build-docker --edition enterprise --shouldSave --ubuntu + depends_on: + - copy-packages-for-docker + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images-ubuntu + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./scripts/grafana-server/start-server + depends_on: + - build-plugins + - build-backend + - build-frontend + - build-frontend-packages detach: true environment: PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz PORT: 3001 - RUNDIR: e2e/tmp-grafana-enterprise - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + RUNDIR: scripts/grafana-server/tmp-grafana-enterprise + image: grafana/build-container:1.5.3 + name: grafana-server - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 --tries 3 + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite dashboards-suite --tries 3 depends_on: - - end-to-end-tests-server + - package environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-dashboards-suite - commands: - - ls dist/*.tar.gz* - - cp dist/*.tar.gz* packaging/docker/ + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite smoke-tests-suite --tries 3 depends_on: - package - image: grafana/build-container:1.4.5 - name: copy-packages-for-docker -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images - settings: - dry_run: false - edition: enterprise - password: - from_secret: docker_password - ubuntu: false - username: - from_secret: docker_user -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images-ubuntu - settings: - dry_run: false - edition: enterprise - password: - from_secret: docker_password - ubuntu: true - username: - from_secret: docker_user + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-smoke-tests-suite - commands: - - dockerize -wait tcp://redis:6379/0 -timeout 120s - - ./bin/grabpl integration-tests + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite panels-suite --tries 3 depends_on: - - grabpl + - package environment: - REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.4.5 - name: redis-integration-tests + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-panels-suite - commands: - - dockerize -wait tcp://memcached:11211 -timeout 120s - - ./bin/grabpl integration-tests + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite various-suite --tries 3 depends_on: - - grabpl + - package environment: - MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.4.5 - name: memcached-integration-tests + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-various-suite - commands: - - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + - ./bin/grabpl upload-cdn --edition enterprise --src-bucket "$${PRERELEASE_BUCKET}" + --src-dir artifacts/static-assets depends_on: - - end-to-end-tests-server + - package environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets - commands: - - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket $${PRERELEASE_BUCKET}/artifacts/downloads depends_on: - - end-to-end-tests - - redis-integration-tests - - memcached-integration-tests + - package environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages - commands: - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --no-pull-enterprise --sign ${DRONE_TAG} + --sign ${DRONE_TAG} depends_on: - build-plugins - build-backend - build-frontend + - build-frontend-packages - build-backend-enterprise2 - - test-backend-enterprise2 environment: GITHUB_TOKEN: from_secret: github_token @@ -1528,227 +2039,205 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package-enterprise2 - commands: - - ./e2e/start-server + - ./bin/grabpl upload-cdn --edition enterprise2 --src-bucket "$${PRERELEASE_BUCKET}" + --src-dir artifacts/static-assets depends_on: - package-enterprise2 - detach: true - environment: - PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz - PORT: 3002 - RUNDIR: e2e/tmp-grafana-enterprise2 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server-enterprise2 -- commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3002 --tries 3 - depends_on: - - end-to-end-tests-server-enterprise2 environment: - HOST: end-to-end-tests-server-enterprise2 - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests-enterprise2 -- commands: - - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" - depends_on: - - end-to-end-tests-server-enterprise2 - environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets-enterprise2 - commands: - - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket $${PRERELEASE_BUCKET}/artifacts/downloads-enterprise2 depends_on: - - end-to-end-tests-enterprise2 - - redis-integration-tests - - memcached-integration-tests + - package-enterprise2 environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages-enterprise2 trigger: + event: + exclude: + - promote ref: - refs/tags/v* + repo: + exclude: + - grafana/grafana type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- clone: disable: true -depends_on: -- enterprise-build-release +depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-windows-release +name: enterprise-test-release +node: + type: no-parallel platform: arch: amd64 - os: windows - version: "1809" + os: linux services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - - echo $env:DRONE_RUNNER_NAME - image: mcr.microsoft.com/windows:1809 + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 name: identify-runner - commands: - - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/windows/grabpl.exe - -OutFile grabpl.exe - - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" + - 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 - image: grafana/ci-wix:0.1.1 - name: clone + image: grafana/build-container:1.5.3 + name: clone-enterprise - 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 + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise + ${DRONE_TAG} + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + - make gen-go + - ./bin/grabpl verify-version ${DRONE_TAG} + - ./bin/grabpl gen-version ${DRONE_TAG} + - yarn install --immutable depends_on: - - clone - image: grafana/ci-wix:0.1.1 + - clone-enterprise + environment: + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.5.3 name: initialize - 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/ + - |- + echo -e "unknwon + referer + errorstring + eror + iam + wan" > words_to_ignore.txt + - codespell -I words_to_ignore.txt docs/ + - rm words_to_ignore.txt + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: codespell +- commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: shellcheck +- commands: + - ./bin/grabpl lint-backend --edition enterprise depends_on: - initialize environment: - GCP_KEY: - from_secret: gcp_key - image: grafana/ci-wix:0.1.1 - name: build-windows-installer + CGO_ENABLED: "1" + image: grafana/build-container:1.5.3 + name: lint-backend +- commands: + - yarn run prettier:check + - yarn run lint + - yarn run typecheck + depends_on: + - initialize + environment: + TEST_MAX_WORKERS: 50% + image: grafana/build-container:1.5.3 + name: lint-frontend +- commands: + - ./bin/grabpl test-backend --edition enterprise + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: test-backend +- commands: + - ./bin/grabpl integration-tests --edition enterprise + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: test-backend-integration +- commands: + - yarn run ci:test-frontend + depends_on: + - initialize + environment: + TEST_MAX_WORKERS: 50% + image: grafana/build-container:1.5.3 + name: test-frontend +- commands: + - ./bin/grabpl lint-backend --edition enterprise2 + depends_on: + - initialize + environment: + CGO_ENABLED: "1" + image: grafana/build-container:1.5.3 + name: lint-backend-enterprise2 +- commands: + - ./bin/grabpl test-backend --edition enterprise2 + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: test-backend-enterprise2 +- commands: + - ./bin/grabpl integration-tests --edition enterprise2 + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: test-backend-integration-enterprise2 trigger: + event: + exclude: + - promote ref: - refs/tags/v* + repo: + exclude: + - grafana/grafana type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- -depends_on: -- oss-build-release -- oss-windows-release -- enterprise-build-release -- enterprise-windows-release +clone: + disable: true +depends_on: [] +image_pull_secrets: +- dockerconfigjson kind: pipeline -name: publish-release -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl -- commands: - - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 - name: identify-runner -- commands: - - make gen-go - - ./bin/grabpl verify-version ${DRONE_TAG} - image: grafana/build-container:1.4.5 - name: initialize -- commands: - - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - - ./bin/grabpl publish-packages --edition oss --gcp-key /tmp/gcpkey.json ${DRONE_TAG} - depends_on: - - initialize - 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 - image: grafana/grafana-ci-deploy:1.3.1 - name: publish-packages-oss -- commands: - - printenv GCP_KEY | base64 -d > /tmp/gcpkey.json - - ./bin/grabpl publish-packages --edition enterprise --gcp-key /tmp/gcpkey.json - ${DRONE_TAG} - depends_on: - - initialize - 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 - image: grafana/grafana-ci-deploy:1.3.1 - name: publish-packages-enterprise -trigger: - ref: - - refs/tags/v* -type: docker ---- -depends_on: -- oss-build-release -- oss-windows-release -- enterprise-build-release -- enterprise-windows-release -- publish-release -kind: pipeline -name: notify-release -platform: - arch: amd64 - os: linux -steps: -- image: plugins/slack - name: slack - settings: - channel: grafana-ci-notifications - template: |- - Build {{build.number}} failed for commit: : {{build.link}} - Branch: - Author: {{build.author}} - webhook: - from_secret: slack_webhook -trigger: - ref: - - refs/tags/v* - status: - - failure -type: docker ---- -depends_on: [] -kind: pipeline -name: oss-build-test-release +name: enterprise-integration-tests-release node: type: no-parallel platform: @@ -1756,11 +2245,15 @@ platform: os: linux services: - environment: + PGDATA: /var/lib/postgresql/data/pgdata POSTGRES_DB: grafanatest POSTGRES_PASSWORD: grafanatest POSTGRES_USER: grafanatest image: postgres:12.3-alpine name: postgres + volumes: + - name: postgres + path: /var/lib/postgresql/data/pgdata - environment: MYSQL_DATABASE: grafana_tests MYSQL_PASSWORD: password @@ -1768,88 +2261,55 @@ services: MYSQL_USER: grafana image: mysql:5.6.48 name: mysql + volumes: + - name: mysql + path: /var/lib/mysql +- environment: {} + image: redis:6.2.1-alpine + name: redis +- environment: {} + image: memcached:1.6.9-alpine + name: memcached steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - - make gen-go - - ./bin/grabpl verify-version v7.3.0-test - - ./bin/grabpl gen-version v7.3.0-test - - yarn install --immutable - image: grafana/build-container:1.4.5 - name: initialize -- commands: - - ./bin/grabpl verify-drone - depends_on: - - grabpl - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone -- commands: - - |- - echo -e "unknwon - referer - errorstring - eror - iam - wan" > words_to_ignore.txt - - codespell -I words_to_ignore.txt docs/ - - rm words_to_ignore.txt - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: shellcheck -- commands: - - ./bin/grabpl lint-backend --edition oss - depends_on: - - initialize - environment: - CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 - name: lint-backend -- commands: - - yarn run prettier:check - - yarn run lint - - yarn run typecheck - depends_on: - - initialize + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_TAG} environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: lint-frontend -- commands: - - ./bin/grabpl test-backend --edition oss - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend -- commands: - - ./bin/grabpl integration-tests --edition oss - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.5.3 + name: clone-enterprise - commands: - - yarn run ci:test-frontend + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise --github-token $${GITHUB_TOKEN} /tmp/grafana-enterprise + ${DRONE_TAG} + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + - make gen-go + - ./bin/grabpl verify-version ${DRONE_TAG} + - ./bin/grabpl gen-version ${DRONE_TAG} + - yarn install --immutable depends_on: - - initialize + - clone-enterprise environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: test-frontend + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.5.3 + name: initialize - commands: - apt-get update - apt-get install -yq postgresql-client @@ -1858,12 +2318,12 @@ steps: - go clean -testcache - ./bin/grabpl integration-tests --database postgres depends_on: - - grabpl + - initialize environment: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: postgres-integration-tests - commands: - apt-get update @@ -1874,423 +2334,672 @@ steps: - go clean -testcache - ./bin/grabpl integration-tests --database mysql depends_on: - - grabpl + - initialize environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: mysql-integration-tests - commands: - - ./bin/grabpl build-backend --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} - --no-pull-enterprise v7.3.0-test + - dockerize -wait tcp://redis:6379/0 -timeout 120s + - ./bin/grabpl integration-tests depends_on: - initialize environment: - GITHUB_TOKEN: - from_secret: github_token - image: grafana/build-container:1.4.5 - name: build-backend -- 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 - image: grafana/build-container:1.4.5 - name: build-frontend + REDIS_URL: redis://redis:6379/0 + image: grafana/build-container:1.5.3 + name: redis-integration-tests - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + - dockerize -wait tcp://memcached:11211 -timeout 120s + - ./bin/grabpl integration-tests depends_on: - initialize environment: - GRAFANA_API_KEY: - from_secret: grafana_api_key - image: grafana/build-container:1.4.5 - name: build-plugins -- commands: - - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . - depends_on: - - build-backend - image: grafana/build-container:1.4.5 - name: validate-scuemata + MEMCACHED_HOSTS: memcached:11211 + image: grafana/build-container:1.5.3 + name: memcached-integration-tests +trigger: + event: + exclude: + - promote + ref: + - refs/tags/v* + repo: + exclude: + - grafana/grafana +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- +clone: + disable: true +depends_on: +- enterprise-build-e2e-publish-release +- enterprise-test-release +- enterprise-integration-tests-release +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: enterprise-windows-release +platform: + arch: amd64 + os: windows + version: "1809" +services: [] +steps: - commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - - '# It is required that the generated Typescript be in sync with the input CUE - files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop - depends_on: - - validate-scuemata - image: grafana/build-container:1.4.5 - name: ensure-cuetsified + - echo $env:DRONE_RUNNER_NAME + image: mcr.microsoft.com/windows:1809 + name: identify-runner - commands: - - ./bin/grabpl package --jobs 8 --edition oss --github-token $${GITHUB_TOKEN} --no-pull-enterprise - --sign v7.3.0-test - depends_on: - - build-plugins - - build-backend - - build-frontend + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/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 - 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 - image: grafana/build-container:1.4.5 - name: package + image: grafana/ci-wix:0.1.1 + name: clone - commands: - - ./e2e/start-server + - 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 --github-token $$env:GITHUB_TOKEN C:\App\grafana-enterprise + - cp C:\App\grabpl.exe grabpl.exe depends_on: - - package - detach: true + - clone environment: - PORT: 3001 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + GITHUB_TOKEN: + from_secret: github_token + image: grafana/ci-wix:0.1.1 + name: initialize +- 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 gen-version ${DRONE_TAG} + - .\grabpl.exe windows-installer --edition enterprise ${DRONE_TAG} + - $$fname = ((Get-Childitem grafana*.msi -name) -split "`n")[0] + - gsutil cp $$fname gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/enterprise/release/ + - gsutil cp "$$fname.sha256" gs://%PRERELEASE_BUCKET%/artifacts/downloads/${DRONE_TAG}/enterprise/release/ + depends_on: + - initialize + environment: + GCP_KEY: + from_secret: gcp_key + GITHUB_TOKEN: + from_secret: github_token + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/ci-wix:0.1.1 + name: build-windows-installer +trigger: + event: + exclude: + - promote + ref: + - refs/tags/v* + repo: + exclude: + - grafana/grafana +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-docker-oss-public +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 --tries 3 + - ./bin/grabpl artifacts docker fetch --version-tag ${TAG} --edition oss --base + alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 depends_on: - - end-to-end-tests-server + - grabpl environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-oss + volumes: + - name: docker + path: /var/run/docker.sock - commands: - - ls dist/*.tar.gz* - - cp dist/*.tar.gz* packaging/docker/ + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana --base alpine --base + ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag ${TAG} depends_on: - - package - image: grafana/build-container:1.4.5 - name: copy-packages-for-docker -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images - settings: - dry_run: true - edition: oss - ubuntu: false -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images-ubuntu - settings: - dry_run: true - edition: oss - ubuntu: true + - fetch-images-oss + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana + volumes: + - name: docker + path: /var/run/docker.sock - commands: - - yarn storybook:build - - ./bin/grabpl verify-storybook + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana-oss --base alpine + --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag ${TAG} depends_on: - - build-frontend + - fetch-images-oss environment: - NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.5 - name: build-storybook + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana-oss + volumes: + - name: docker + path: /var/run/docker.sock +trigger: + event: + - promote + target: + - public +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-docker-enterprise-public +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - ./bin/grabpl artifacts docker fetch --version-tag ${TAG} --edition enterprise + --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 + depends_on: + - grabpl + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-enterprise + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana-enterprise --base + alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag ${TAG} + depends_on: + - fetch-images-enterprise + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana-enterprise + volumes: + - name: docker + path: /var/run/docker.sock +trigger: + event: + - promote + target: + - public +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-docker-oss-security +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: - commands: - - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - ./bin/grabpl artifacts docker fetch --version-tag ${TAG} --edition oss --base + alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 depends_on: - - end-to-end-tests-server + - grabpl environment: - GCP_GRAFANA_UPLOAD_KEY: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-oss + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl artifacts docker publish --security --dockerhub-repo grafana --base + alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag ${TAG} + depends_on: + - fetch-images-oss + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl artifacts docker publish --security --dockerhub-repo grafana-oss + --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag + ${TAG} + depends_on: + - fetch-images-oss + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana-oss + volumes: + - name: docker + path: /var/run/docker.sock +trigger: + event: + - promote + target: + - security +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-docker-enterprise-security +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - ./bin/grabpl artifacts docker fetch --version-tag ${TAG} --edition enterprise + --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 + depends_on: + - grabpl + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: fetch-images-enterprise + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl artifacts docker publish --security --dockerhub-repo grafana-enterprise + --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag + ${TAG} + depends_on: + - fetch-images-enterprise + environment: + DOCKER_PASSWORD: + from_secret: docker_password + DOCKER_USER: + from_secret: docker_username + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: publish-images-grafana-enterprise + volumes: + - name: docker + path: /var/run/docker.sock +trigger: + event: + - promote + target: + - security +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-artifacts-security +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - ./bin/grabpl artifacts publish --security --tag ${TAG} --src-bucket grafana-prerelease + depends_on: + - grabpl + environment: + GCP_KEY: from_secret: gcp_key image: grafana/grafana-ci-deploy:1.3.1 - name: upload-cdn-assets + name: publish-artifacts +trigger: + event: + - promote + target: + - security +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-artifacts-public +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: - commands: - - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads-test + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - ./bin/grabpl artifacts publish --tag ${TAG} --src-bucket grafana-prerelease depends_on: - - end-to-end-tests + - grabpl environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key image: grafana/grafana-ci-deploy:1.3.1 - name: upload-packages + name: publish-artifacts +trigger: + event: + - promote + target: + - public +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline +name: publish-npm-packages-public +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl verify-version ${DRONE_TAG} + - ./bin/grabpl gen-version ${DRONE_TAG} + - yarn install --immutable + image: grafana/build-container:1.5.3 + name: initialize +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl - commands: - - echo Testing release + - ./bin/grabpl artifacts npm retrieve --tag v${TAG} depends_on: - - build-storybook - - end-to-end-tests + - initialize environment: GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 - name: publish-storybook -- commands: [] + name: retrieve-npm-packages +- commands: + - ./bin/grabpl artifacts npm release --tag v${TAG} depends_on: - - publish-storybook + - retrieve-npm-packages environment: - GITHUB_PACKAGE_TOKEN: - from_secret: github_package_token NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: release-npm-packages trigger: event: - - custom + - promote + target: + - public type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- -depends_on: -- oss-build-test-release +depends_on: [] kind: pipeline -name: oss-windows-test-release +name: publish-packages +node: + type: no-parallel platform: arch: amd64 - os: windows - version: "1809" + os: linux services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - - echo $env:DRONE_RUNNER_NAME - image: mcr.microsoft.com/windows:1809 - name: identify-runner -- commands: - - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/windows/grabpl.exe - -OutFile grabpl.exe - image: grafana/ci-wix:0.1.1 - name: initialize + - ./bin/grabpl store-packages --edition oss --packages-bucket grafana-downloads + --gcp-key /tmp/gcpkey.json ${DRONE_TAG} + depends_on: + - grabpl + 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 + image: grafana/grafana-ci-deploy:1.3.1 + name: store-packages-oss - 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/ + - ./bin/grabpl store-packages --edition enterprise --packages-bucket grafana-downloads + --gcp-key /tmp/gcpkey.json ${DRONE_TAG} depends_on: - - initialize + - grabpl environment: GCP_KEY: from_secret: gcp_key - image: grafana/ci-wix:0.1.1 - name: build-windows-installer + 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 + image: grafana/grafana-ci-deploy:1.3.1 + name: store-packages-enterprise trigger: event: - - custom + - promote + target: + - public type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- -clone: - disable: true depends_on: [] -image_pull_secrets: -- dockerconfigjson kind: pipeline -name: enterprise-build-test-release +name: oss-build-publish-e2e-release-branch node: type: no-parallel platform: arch: amd64 os: linux -services: -- environment: - POSTGRES_DB: grafanatest - POSTGRES_PASSWORD: grafanatest - POSTGRES_USER: grafanatest - image: postgres:12.3-alpine - name: postgres -- environment: - MYSQL_DATABASE: grafana_tests - MYSQL_PASSWORD: password - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_USER: grafana - image: mysql:5.6.48 - name: mysql -- environment: {} - image: redis:6.2.1-alpine - name: redis -- environment: {} - image: memcached:1.6.9-alpine - name: memcached +services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" - - cd grafana-enterprise - - git checkout main - environment: - GITHUB_TOKEN: - from_secret: github_token - image: grafana/build-container:1.4.5 - name: clone -- commands: - - mv bin/grabpl /tmp/ - - rmdir bin - - mv grafana-enterprise /tmp/ - - /tmp/grabpl init-enterprise /tmp/grafana-enterprise - - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - - mkdir bin - - mv /tmp/grabpl bin/ - make gen-go - - ./bin/grabpl verify-version v7.3.0-test - - ./bin/grabpl gen-version v7.3.0-test + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - depends_on: - - grabpl - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize - commands: - - ./bin/grabpl verify-drone - depends_on: - - grabpl - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone -- commands: - - |- - echo -e "unknwon - referer - errorstring - eror - iam - wan" > words_to_ignore.txt - - codespell -I words_to_ignore.txt docs/ - - rm words_to_ignore.txt - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: codespell -- commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: shellcheck -- commands: - - ./bin/grabpl lint-backend --edition enterprise - depends_on: - - initialize - environment: - CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 - name: lint-backend -- commands: - - yarn run prettier:check - - yarn run lint - - yarn run typecheck - depends_on: - - initialize - environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: lint-frontend -- commands: - - ./bin/grabpl test-backend --edition enterprise - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend -- commands: - - ./bin/grabpl integration-tests --edition enterprise + - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration + environment: {} + image: grafana/build-container:1.5.3 + name: build-backend - commands: - - yarn run ci:test-frontend + - ./bin/grabpl build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: test-frontend -- commands: - - apt-get update - - apt-get install -yq postgresql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: postgres - PGPASSWORD: grafanatest - POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 - name: postgres-integration-tests -- commands: - - apt-get update - - apt-get install -yq default-mysql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: mysql - MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 - name: mysql-integration-tests + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend - commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --no-pull-enterprise v7.3.0-test + - ./bin/grabpl build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize environment: - GITHUB_TOKEN: - from_secret: github_token - image: grafana/build-container:1.4.5 - name: build-backend + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages - 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 - image: grafana/build-container:1.4.5 - name: build-frontend -- commands: - - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign - --signing-admin + - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -2311,47 +3020,15 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl lint-backend --edition enterprise2 - depends_on: - - initialize - environment: - CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 - name: lint-backend-enterprise2 -- commands: - - ./bin/grabpl test-backend --edition enterprise2 - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-enterprise2 -- commands: - - ./bin/grabpl integration-tests --edition enterprise2 - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration-enterprise2 -- commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --no-pull-enterprise v7.3.0-test - depends_on: - - initialize - environment: - GITHUB_TOKEN: - from_secret: github_token - image: grafana/build-container:1.4.5 - name: build-backend-enterprise2 -- commands: - - ./bin/grabpl package --jobs 8 --edition enterprise --github-token $${GITHUB_TOKEN} - --no-pull-enterprise --sign v7.3.0-test + - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign depends_on: - build-plugins - build-backend - build-frontend - - build-backend-enterprise2 - - test-backend-enterprise2 + - build-frontend-packages environment: GITHUB_TOKEN: from_secret: github_token @@ -2363,235 +3040,140 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package -- commands: - - ./e2e/start-server - depends_on: - - package - detach: true - environment: - PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz - PORT: 3001 - RUNDIR: e2e/tmp-grafana-enterprise - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server -- commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 --tries 3 - depends_on: - - end-to-end-tests-server - environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: copy-packages-for-docker -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images - settings: - dry_run: true - edition: enterprise - ubuntu: false -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images-ubuntu - settings: - dry_run: true - edition: enterprise - ubuntu: true -- commands: - - dockerize -wait tcp://redis:6379/0 -timeout 120s - - ./bin/grabpl integration-tests - depends_on: - - grabpl - environment: - REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.4.5 - name: redis-integration-tests -- commands: - - dockerize -wait tcp://memcached:11211 -timeout 120s - - ./bin/grabpl integration-tests - depends_on: - - grabpl - environment: - MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.4.5 - name: memcached-integration-tests - commands: - - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" + - ./bin/grabpl build-docker --edition oss --shouldSave depends_on: - - end-to-end-tests-server + - copy-packages-for-docker environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-cdn-assets + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock - commands: - - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads-test + - ./bin/grabpl build-docker --edition oss --shouldSave --ubuntu depends_on: - - end-to-end-tests - - redis-integration-tests - - memcached-integration-tests + - copy-packages-for-docker environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-packages + image: google/cloud-sdk + name: build-docker-images-ubuntu + volumes: + - name: docker + path: /var/run/docker.sock - commands: - - ./bin/grabpl package --jobs 8 --edition enterprise2 --github-token $${GITHUB_TOKEN} - --no-pull-enterprise --sign v7.3.0-test + - ./scripts/grafana-server/start-server depends_on: - build-plugins - build-backend - build-frontend - - build-backend-enterprise2 - - test-backend-enterprise2 + - build-frontend-packages + detach: true 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 - image: grafana/build-container:1.4.5 - name: package-enterprise2 + PORT: 3001 + image: grafana/build-container:1.5.3 + name: grafana-server - commands: - - ./e2e/start-server + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite dashboards-suite --tries 3 depends_on: - - package-enterprise2 - detach: true + - package environment: - PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz - PORT: 3002 - RUNDIR: e2e/tmp-grafana-enterprise2 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server-enterprise2 + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-dashboards-suite - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3002 --tries 3 + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite smoke-tests-suite --tries 3 depends_on: - - end-to-end-tests-server-enterprise2 + - package environment: - HOST: end-to-end-tests-server-enterprise2 - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests-enterprise2 + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-smoke-tests-suite - commands: - - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite panels-suite --tries 3 depends_on: - - end-to-end-tests-server-enterprise2 + - package environment: - GCP_GRAFANA_UPLOAD_KEY: - from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-cdn-assets-enterprise2 + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-panels-suite - commands: - - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-test + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite various-suite --tries 3 depends_on: - - end-to-end-tests-enterprise2 - - redis-integration-tests - - memcached-integration-tests + - package environment: - GCP_GRAFANA_UPLOAD_KEY: - from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-packages-enterprise2 -trigger: - event: - - custom -type: docker ---- -clone: - disable: true -depends_on: -- enterprise-build-test-release -image_pull_secrets: -- dockerconfigjson -kind: pipeline -name: enterprise-windows-test-release -platform: - arch: amd64 - os: windows - version: "1809" -services: [] -steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl -- commands: - - echo $env:DRONE_RUNNER_NAME - image: mcr.microsoft.com/windows:1809 - name: identify-runner + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-various-suite - commands: - - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.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 + - yarn storybook:build + - ./bin/grabpl verify-storybook + depends_on: + - build-frontend + - build-frontend-packages environment: - GITHUB_TOKEN: - from_secret: github_token - image: grafana/ci-wix:0.1.1 - name: clone + NODE_OPTIONS: --max_old_space_size=4096 + image: grafana/build-container:1.5.3 + name: build-storybook - 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 + - ./bin/grabpl upload-cdn --edition oss --src-bucket "grafana-static-assets" depends_on: - - clone - image: grafana/ci-wix:0.1.1 - name: initialize + - grafana-server + environment: + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.1 + name: upload-cdn-assets - 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/ + - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads depends_on: - - initialize + - end-to-end-tests-dashboards-suite + - end-to-end-tests-panels-suite + - end-to-end-tests-smoke-tests-suite + - end-to-end-tests-various-suite environment: GCP_KEY: from_secret: gcp_key - image: grafana/ci-wix:0.1.1 - name: build-windows-installer + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.1 + name: upload-packages trigger: - event: - - custom + ref: + - refs/heads/v[0-9]* type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- -depends_on: -- oss-build-test-release -- oss-windows-test-release -- enterprise-build-test-release -- enterprise-windows-test-release +depends_on: [] kind: pipeline -name: publish-test-release +name: oss-test-release-branch node: type: no-parallel platform: @@ -2601,98 +3183,90 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - - ./bin/grabpl verify-version v7.3.0-test - image: grafana/build-container:1.4.5 + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.5.3 name: initialize - 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 + - |- + echo -e "unknwon + referer + errorstring + eror + iam + wan" > words_to_ignore.txt + - codespell -I words_to_ignore.txt docs/ + - rm words_to_ignore.txt + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: codespell +- commands: + - ./bin/grabpl shellcheck + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: shellcheck +- commands: + - ./bin/grabpl lint-backend --edition oss depends_on: - initialize 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 - image: grafana/grafana-ci-deploy:1.3.1 - name: publish-packages-oss + CGO_ENABLED: "1" + image: grafana/build-container:1.5.3 + name: lint-backend - 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 + - yarn run prettier:check + - yarn run lint + - yarn run typecheck depends_on: - initialize 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 - image: grafana/grafana-ci-deploy:1.3.1 - name: publish-packages-enterprise -trigger: - event: - - custom -type: docker ---- -depends_on: -- oss-build-test-release -- oss-windows-test-release -- enterprise-build-test-release -- enterprise-windows-test-release -- publish-test-release -kind: pipeline -name: notify-test-release -platform: - arch: amd64 - os: linux -steps: -- image: plugins/slack - name: slack - settings: - channel: grafana-ci-notifications - template: |- - Build {{build.number}} failed for commit: : {{build.link}} - Branch: - Author: {{build.author}} - webhook: - from_secret: slack_webhook + TEST_MAX_WORKERS: 50% + image: grafana/build-container:1.5.3 + name: lint-frontend +- commands: + - ./bin/grabpl test-backend --edition oss + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: test-backend +- commands: + - ./bin/grabpl integration-tests --edition oss + depends_on: + - initialize + image: grafana/build-container:1.5.3 + name: test-backend-integration +- commands: + - yarn run ci:test-frontend + depends_on: + - initialize + environment: + TEST_MAX_WORKERS: 50% + image: grafana/build-container:1.5.3 + name: test-frontend trigger: - event: - - custom - status: - - failure + ref: + - refs/heads/v[0-9]* type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- depends_on: [] kind: pipeline -name: oss-build-release-branch +name: oss-integration-tests-release-branch node: type: no-parallel platform: @@ -2700,11 +3274,15 @@ platform: os: linux services: - environment: + PGDATA: /var/lib/postgresql/data/pgdata POSTGRES_DB: grafanatest POSTGRES_PASSWORD: grafanatest POSTGRES_USER: grafanatest image: postgres:12.3-alpine name: postgres + volumes: + - name: postgres + path: /var/lib/postgresql/data/pgdata - environment: MYSQL_DATABASE: grafana_tests MYSQL_PASSWORD: password @@ -2712,146 +3290,209 @@ services: MYSQL_USER: grafana image: mysql:5.6.48 name: mysql + volumes: + - name: mysql + path: /var/lib/mysql steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - make gen-go - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: initialize - commands: - - ./bin/grabpl verify-drone + - apt-get update + - apt-get install -yq postgresql-client + - 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 depends_on: - grabpl - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone + environment: + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + image: grafana/build-container:1.5.3 + name: postgres-integration-tests - commands: - - |- - echo -e "unknwon - referer - errorstring - eror - iam - wan" > words_to_ignore.txt - - codespell -I words_to_ignore.txt docs/ - - rm words_to_ignore.txt + - apt-get update + - apt-get install -yq default-mysql-client + - 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 depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: codespell + - grabpl + environment: + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + image: grafana/build-container:1.5.3 + name: mysql-integration-tests +trigger: + ref: + - refs/heads/v[0-9]* +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory +--- +depends_on: +- oss-build-publish-e2e-release-branch +- oss-test-release-branch +- oss-integration-tests-release-branch +kind: pipeline +name: oss-windows-release-branch +platform: + arch: amd64 + os: windows + version: "1809" +services: [] +steps: - commands: - - ./bin/grabpl shellcheck - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: shellcheck + - echo $env:DRONE_RUNNER_NAME + image: mcr.microsoft.com/windows:1809 + name: identify-runner - commands: - - ./bin/grabpl lint-backend --edition oss - depends_on: - - initialize - environment: - CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 - name: lint-backend + - $$ProgressPreference = "SilentlyContinue" + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/windows/grabpl.exe + -OutFile grabpl.exe + image: grafana/ci-wix:0.1.1 + name: initialize - commands: - - yarn run prettier:check - - yarn run lint - - yarn run typecheck + - $$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 . depends_on: - initialize environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: lint-frontend -- commands: - - ./bin/grabpl test-backend --edition oss - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend + GCP_KEY: + from_secret: gcp_key + GITHUB_TOKEN: + from_secret: github_token + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/ci-wix:0.1.1 + name: build-windows-installer +trigger: + ref: + - refs/heads/v[0-9]* +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +clone: + disable: true +depends_on: [] +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: enterprise-build-e2e-publish-release-branch +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: - commands: - - ./bin/grabpl integration-tests --edition oss - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl - commands: - - yarn run ci:test-frontend - depends_on: - - initialize - environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: test-frontend + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner - commands: - - apt-get update - - apt-get install -yq postgresql-client - - 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 - depends_on: - - grabpl + - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" + - cd grafana-enterprise + - git checkout ${DRONE_BRANCH} environment: - GRAFANA_TEST_DB: postgres - PGPASSWORD: grafanatest - POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 - name: postgres-integration-tests + GITHUB_TOKEN: + from_secret: github_token + image: grafana/build-container:1.5.3 + name: clone-enterprise - commands: - - apt-get update - - apt-get install -yq default-mysql-client - - 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 + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: mysql - MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 - name: mysql-integration-tests + - clone-enterprise + environment: {} + image: grafana/build-container:1.5.3 + name: initialize - commands: - - ./bin/grabpl build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise + - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize environment: {} - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - initialize - image: grafana/build-container:1.4.5 + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 name: build-frontend - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition oss --no-install-deps --sign --signing-admin + - ./bin/grabpl build-frontend-packages --jobs 8 --edition enterprise --build-id + ${DRONE_BUILD_NUMBER} + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.5.3 + name: build-frontend-packages +- commands: + - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign + --signing-admin depends_on: - initialize environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-plugins - commands: - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . depends_on: - build-backend - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: validate-scuemata - commands: - '# Make sure the git tree is clean.' @@ -2872,15 +3513,25 @@ steps: - git stash pop depends_on: - validate-scuemata - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: ensure-cuetsified - commands: - - ./bin/grabpl package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} + --variants linux-x64 + depends_on: + - initialize + environment: {} + image: grafana/build-container:1.5.3 + name: build-backend-enterprise2 +- commands: + - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} --sign depends_on: - build-plugins - build-backend - build-frontend + - build-frontend-packages + - build-backend-enterprise2 environment: GITHUB_TOKEN: from_secret: github_token @@ -2892,126 +3543,179 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: package - commands: - - ./e2e/start-server + - ls dist/*.tar.gz* + - cp dist/*.tar.gz* packaging/docker/ depends_on: - package + image: grafana/build-container:1.5.3 + name: copy-packages-for-docker +- commands: + - ./bin/grabpl build-docker --edition enterprise --shouldSave + depends_on: + - copy-packages-for-docker + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./bin/grabpl build-docker --edition enterprise --shouldSave --ubuntu + depends_on: + - copy-packages-for-docker + environment: + GCP_KEY: + from_secret: gcp_key + image: google/cloud-sdk + name: build-docker-images-ubuntu + volumes: + - name: docker + path: /var/run/docker.sock +- commands: + - ./scripts/grafana-server/start-server + depends_on: + - build-plugins + - build-backend + - build-frontend + - build-frontend-packages detach: true environment: + PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz PORT: 3001 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + RUNDIR: scripts/grafana-server/tmp-grafana-enterprise + image: grafana/build-container:1.5.3 + name: grafana-server - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 --tries 3 + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite dashboards-suite --tries 3 depends_on: - - end-to-end-tests-server + - package environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-dashboards-suite - commands: - - ls dist/*.tar.gz* - - cp dist/*.tar.gz* packaging/docker/ + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite smoke-tests-suite --tries 3 depends_on: - package - image: grafana/build-container:1.4.5 - name: copy-packages-for-docker -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images - settings: - dry_run: true - edition: oss - ubuntu: false -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images-ubuntu - settings: - dry_run: true - edition: oss - ubuntu: true + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-smoke-tests-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite panels-suite --tries 3 + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-panels-suite +- commands: + - apt-get install -y netcat + - ./bin/grabpl e2e-tests --port 3001 --suite various-suite --tries 3 + depends_on: + - package + environment: + HOST: grafana-server + image: cypress/included:8.4.1 + name: end-to-end-tests-various-suite - commands: - yarn storybook:build - ./bin/grabpl verify-storybook depends_on: - build-frontend + - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: build-storybook - commands: - - ./bin/grabpl upload-cdn --edition oss --bucket "grafana-static-assets" + - ./bin/grabpl upload-cdn --edition enterprise --src-bucket "grafana-static-assets" depends_on: - - end-to-end-tests-server + - package environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-cdn-assets - commands: - - ./bin/grabpl upload-packages --edition oss --packages-bucket grafana-downloads + - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads depends_on: - - end-to-end-tests + - package environment: - GCP_GRAFANA_UPLOAD_KEY: + GCP_KEY: from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/grafana-ci-deploy:1.3.1 name: upload-packages -trigger: - ref: - - refs/heads/v[0-9]* -type: docker ---- -depends_on: -- oss-build-release-branch -kind: pipeline -name: oss-windows-release-branch -platform: - arch: amd64 - os: windows - version: "1809" -services: [] -steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - - echo $env:DRONE_RUNNER_NAME - image: mcr.microsoft.com/windows:1809 - name: identify-runner + - ./bin/grabpl package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} + --variants linux-x64 --sign + depends_on: + - build-plugins + - build-backend + - build-frontend + - build-frontend-packages + - build-backend-enterprise2 + 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 + image: grafana/build-container:1.5.3 + name: package-enterprise2 - commands: - - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/windows/grabpl.exe - -OutFile grabpl.exe - image: grafana/ci-wix:0.1.1 - name: initialize + - ./bin/grabpl upload-cdn --edition enterprise2 --src-bucket "grafana-static-assets" + depends_on: + - package-enterprise2 + environment: + GCP_KEY: + from_secret: gcp_key + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.1 + name: upload-cdn-assets-enterprise2 - 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 . + - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 depends_on: - - initialize + - package-enterprise2 environment: GCP_KEY: from_secret: gcp_key - image: grafana/ci-wix:0.1.1 - name: build-windows-installer + PRERELEASE_BUCKET: + from_secret: prerelease_bucket + image: grafana/grafana-ci-deploy:1.3.1 + name: upload-packages-enterprise2 trigger: ref: - refs/heads/v[0-9]* type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- clone: disable: true @@ -3019,42 +3723,23 @@ depends_on: [] image_pull_secrets: - dockerconfigjson kind: pipeline -name: enterprise-build-release-branch +name: enterprise-test-release-branch node: type: no-parallel platform: arch: amd64 os: linux -services: -- environment: - POSTGRES_DB: grafanatest - POSTGRES_PASSWORD: grafanatest - POSTGRES_USER: grafanatest - image: postgres:12.3-alpine - name: postgres -- environment: - MYSQL_DATABASE: grafana_tests - MYSQL_PASSWORD: password - MYSQL_ROOT_PASSWORD: rootpass - MYSQL_USER: grafana - image: mysql:5.6.48 - name: mysql -- environment: {} - image: redis:6.2.1-alpine - name: redis -- environment: {} - image: memcached:1.6.9-alpine - name: memcached +services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - echo $DRONE_RUNNER_NAME - image: alpine:3.14.2 + image: alpine:3.15 name: identify-runner - commands: - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" @@ -3063,13 +3748,13 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.4.5 - name: clone + image: grafana/build-container:1.5.3 + name: clone-enterprise - commands: - mv bin/grabpl /tmp/ - rmdir bin - mv grafana-enterprise /tmp/ - - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json - mkdir bin - mv /tmp/grabpl bin/ @@ -3077,15 +3762,10 @@ steps: - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} - yarn install --immutable depends_on: - - grabpl - image: grafana/build-container:1.4.5 + - clone-enterprise + environment: {} + image: grafana/build-container:1.5.3 name: initialize -- commands: - - ./bin/grabpl verify-drone - depends_on: - - grabpl - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone - commands: - |- echo -e "unknwon @@ -3098,13 +3778,13 @@ steps: - rm words_to_ignore.txt depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: codespell - commands: - ./bin/grabpl shellcheck depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: shellcheck - commands: - ./bin/grabpl lint-backend --edition enterprise @@ -3112,7 +3792,7 @@ steps: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-backend - commands: - yarn run prettier:check @@ -3122,321 +3802,201 @@ steps: - initialize environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-frontend - commands: - - ./bin/grabpl test-backend --edition enterprise - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend -- commands: - - ./bin/grabpl integration-tests --edition enterprise - depends_on: - - initialize - image: grafana/build-container:1.4.5 - name: test-backend-integration -- commands: - - yarn run ci:test-frontend - depends_on: - - initialize - environment: - TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.4.5 - name: test-frontend -- commands: - - apt-get update - - apt-get install -yq postgresql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: postgres - PGPASSWORD: grafanatest - POSTGRES_HOST: postgres - image: grafana/build-container:1.4.5 - name: postgres-integration-tests -- commands: - - apt-get update - - apt-get install -yq default-mysql-client - - 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 - depends_on: - - grabpl - environment: - GRAFANA_TEST_DB: mysql - MYSQL_HOST: mysql - image: grafana/build-container:1.4.5 - name: mysql-integration-tests -- commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise + - ./bin/grabpl test-backend --edition enterprise depends_on: - initialize - environment: {} - image: grafana/build-container:1.4.5 - name: build-backend + image: grafana/build-container:1.5.3 + name: test-backend - commands: - - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition enterprise --build-id - ${DRONE_BUILD_NUMBER} --no-pull-enterprise + - ./bin/grabpl integration-tests --edition enterprise depends_on: - initialize - image: grafana/build-container:1.4.5 - name: build-frontend + image: grafana/build-container:1.5.3 + name: test-backend-integration - commands: - - ./bin/grabpl build-plugins --jobs 8 --edition enterprise --no-install-deps --sign - --signing-admin + - yarn run ci:test-frontend depends_on: - initialize environment: - GRAFANA_API_KEY: - from_secret: grafana_api_key - image: grafana/build-container:1.4.5 - name: build-plugins -- commands: - - ./bin/linux-amd64/grafana-cli cue validate-schema --grafana-root . - depends_on: - - build-backend - image: grafana/build-container:1.4.5 - name: validate-scuemata -- commands: - - '# Make sure the git tree is clean.' - - '# Stashing changes, since packages that were produced in build-backend step are - needed.' - - git stash - - ./bin/linux-amd64/grafana-cli cue gen-ts --grafana-root . - - '# The above command generates Typescript files (*.gen.ts) from all appropriate - .cue files.' - - '# It is required that the generated Typescript be in sync with the input CUE - files.' - - '# ...Modulo eslint auto-fixes...:' - - yarn run eslint . --ext .gen.ts --fix - - '# If any filenames are emitted by the below script, run the generator command - `grafana-cli cue gen-ts` locally and commit the result.' - - ./scripts/clean-git-or-error.sh - - '# Un-stash changes.' - - git stash pop - depends_on: - - validate-scuemata - image: grafana/build-container:1.4.5 - name: ensure-cuetsified + TEST_MAX_WORKERS: 50% + image: grafana/build-container:1.5.3 + name: test-frontend - commands: - ./bin/grabpl lint-backend --edition enterprise2 depends_on: - initialize environment: CGO_ENABLED: "1" - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: lint-backend-enterprise2 - commands: - ./bin/grabpl test-backend --edition enterprise2 depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend-enterprise2 - commands: - ./bin/grabpl integration-tests --edition enterprise2 depends_on: - initialize - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: test-backend-integration-enterprise2 +trigger: + ref: + - refs/heads/v[0-9]* +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +clone: + disable: true +depends_on: [] +image_pull_secrets: +- dockerconfigjson +kind: pipeline +name: enterprise-integration-tests-release-branch +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: +- environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_DB: grafanatest + POSTGRES_PASSWORD: grafanatest + POSTGRES_USER: grafanatest + image: postgres:12.3-alpine + name: postgres + volumes: + - name: postgres + path: /var/lib/postgresql/data/pgdata +- environment: + MYSQL_DATABASE: grafana_tests + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_USER: grafana + image: mysql:5.6.48 + name: mysql + volumes: + - name: mysql + path: /var/lib/mysql +- environment: {} + image: redis:6.2.1-alpine + name: redis +- environment: {} + image: memcached:1.6.9-alpine + name: memcached +steps: - commands: - - ./bin/grabpl build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} - --variants linux-x64 --no-pull-enterprise - depends_on: - - initialize - environment: {} - image: grafana/build-container:1.4.5 - name: build-backend-enterprise2 + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl - commands: - - ./bin/grabpl package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise --sign - depends_on: - - build-plugins - - build-backend - - build-frontend - - build-backend-enterprise2 - - test-backend-enterprise2 + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - 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 - 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 - image: grafana/build-container:1.4.5 - name: package + image: grafana/build-container:1.5.3 + name: clone-enterprise - commands: - - ./e2e/start-server + - mv bin/grabpl /tmp/ + - rmdir bin + - mv grafana-enterprise /tmp/ + - /tmp/grabpl init-enterprise /tmp/grafana-enterprise + - mv /tmp/grafana-enterprise/deployment_tools_config.json deployment_tools_config.json + - mkdir bin + - mv /tmp/grabpl bin/ + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable depends_on: - - package - detach: true - environment: - PACKAGE_FILE: dist/grafana-enterprise-*linux-amd64.tar.gz - PORT: 3001 - RUNDIR: e2e/tmp-grafana-enterprise - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server + - clone-enterprise + environment: {} + image: grafana/build-container:1.5.3 + name: initialize - commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3001 --tries 3 + - apt-get update + - apt-get install -yq postgresql-client + - 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 depends_on: - - end-to-end-tests-server + - initialize environment: - HOST: end-to-end-tests-server - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests -- commands: - - ls dist/*.tar.gz* - - cp dist/*.tar.gz* packaging/docker/ - depends_on: - - package - image: grafana/build-container:1.4.5 - name: copy-packages-for-docker -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images - settings: - dry_run: true - edition: enterprise - ubuntu: false -- depends_on: - - copy-packages-for-docker - image: grafana/drone-grafana-docker:0.3.2 - name: build-docker-images-ubuntu - settings: - dry_run: true - edition: enterprise - ubuntu: true + GRAFANA_TEST_DB: postgres + PGPASSWORD: grafanatest + POSTGRES_HOST: postgres + image: grafana/build-container:1.5.3 + name: postgres-integration-tests - commands: - - yarn storybook:build - - ./bin/grabpl verify-storybook + - apt-get update + - apt-get install -yq default-mysql-client + - 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 depends_on: - - build-frontend + - initialize environment: - NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.4.5 - name: build-storybook + GRAFANA_TEST_DB: mysql + MYSQL_HOST: mysql + image: grafana/build-container:1.5.3 + name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s - ./bin/grabpl integration-tests depends_on: - - grabpl + - initialize environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s - ./bin/grabpl integration-tests depends_on: - - grabpl + - initialize environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.4.5 + image: grafana/build-container:1.5.3 name: memcached-integration-tests -- commands: - - ./bin/grabpl upload-cdn --edition enterprise --bucket "grafana-static-assets" - depends_on: - - end-to-end-tests-server - environment: - GCP_GRAFANA_UPLOAD_KEY: - from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-cdn-assets -- commands: - - ./bin/grabpl upload-packages --edition enterprise --packages-bucket grafana-downloads - depends_on: - - end-to-end-tests - - redis-integration-tests - - memcached-integration-tests - environment: - GCP_GRAFANA_UPLOAD_KEY: - from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-packages -- commands: - - ./bin/grabpl package --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} - --no-pull-enterprise --variants linux-x64 --sign - depends_on: - - build-plugins - - build-backend - - build-frontend - - build-backend-enterprise2 - - test-backend-enterprise2 - 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 - image: grafana/build-container:1.4.5 - name: package-enterprise2 -- commands: - - ./e2e/start-server - depends_on: - - package-enterprise2 - detach: true - environment: - PACKAGE_FILE: dist/grafana-enterprise2-*linux-amd64.tar.gz - PORT: 3002 - RUNDIR: e2e/tmp-grafana-enterprise2 - image: grafana/build-container:1.4.5 - name: end-to-end-tests-server-enterprise2 -- commands: - - yarn run cypress install - - ./bin/grabpl e2e-tests --port 3002 --tries 3 - depends_on: - - end-to-end-tests-server-enterprise2 - environment: - HOST: end-to-end-tests-server-enterprise2 - image: grafana/ci-e2e:12.19.0-1 - name: end-to-end-tests-enterprise2 -- commands: - - ./bin/grabpl upload-cdn --edition enterprise2 --bucket "grafana-static-assets" - depends_on: - - end-to-end-tests-server-enterprise2 - environment: - GCP_GRAFANA_UPLOAD_KEY: - from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-cdn-assets-enterprise2 -- commands: - - ./bin/grabpl upload-packages --edition enterprise2 --packages-bucket grafana-downloads-enterprise2 - depends_on: - - end-to-end-tests-enterprise2 - - redis-integration-tests - - memcached-integration-tests - environment: - GCP_GRAFANA_UPLOAD_KEY: - from_secret: gcp_key - image: grafana/grafana-ci-deploy:1.3.1 - name: upload-packages-enterprise2 trigger: ref: - refs/heads/v[0-9]* type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +- name: postgres + temp: + medium: memory +- name: mysql + temp: + medium: memory --- clone: disable: true depends_on: -- enterprise-build-release-branch +- enterprise-build-e2e-publish-release-branch +- enterprise-test-release-branch +- enterprise-integration-tests-release-branch image_pull_secrets: - dockerconfigjson kind: pipeline @@ -3447,19 +4007,13 @@ platform: version: "1809" services: [] steps: -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl - commands: - echo $env:DRONE_RUNNER_NAME image: mcr.microsoft.com/windows:1809 name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.6.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.50/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -3474,10 +4028,13 @@ steps: - 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 + - C:\App\grabpl.exe init-enterprise --github-token $$env:GITHUB_TOKEN C:\App\grafana-enterprise - cp C:\App\grabpl.exe grabpl.exe depends_on: - clone + environment: + GITHUB_TOKEN: + from_secret: github_token image: grafana/ci-wix:0.1.1 name: initialize - commands: @@ -3493,40 +4050,20 @@ steps: environment: GCP_KEY: from_secret: gcp_key + GITHUB_TOKEN: + from_secret: github_token + PRERELEASE_BUCKET: + from_secret: prerelease_bucket image: grafana/ci-wix:0.1.1 name: build-windows-installer trigger: ref: - refs/heads/v[0-9]* type: docker ---- -depends_on: -- oss-build-release-branch -- oss-windows-release-branch -- enterprise-build-release-branch -- enterprise-windows-release-branch -kind: pipeline -name: notify-release-branch -platform: - arch: amd64 - os: linux -steps: -- image: plugins/slack - name: slack - settings: - channel: grafana-ci-notifications - template: |- - Build {{build.number}} failed for commit: : {{build.link}} - Branch: - Author: {{build.author}} - webhook: - from_secret: slack_webhook -trigger: - ref: - - refs/heads/v[0-9]* - status: - - failure -type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker --- kind: pipeline name: scan-docker-images @@ -3581,7 +4118,13 @@ get: kind: secret name: drone_token --- +get: + name: bucket + path: infra/data/ci/grafana/prerelease +kind: secret +name: prerelease_bucket +--- kind: signature -hmac: 98b4a289aefb0f9aaa664b34ed6d9b36707ec384e93b08eebc45bacecc0a0698 +hmac: 269c5ba0967ad88138b1346e45f3905ccb99d1c437f3e0afd014d0802b0f1f10 ... diff --git a/.eslintignore b/.eslintignore index 7e7361a556f1f..69879bc52b9d8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,4 +6,6 @@ devenv data dist e2e/tmp +scripts/grafana-server/tmp public/lib/monaco +deployment_tools_config.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 165b41d272287..e28575f93763e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -83,7 +83,7 @@ go.sum @grafana/backend-platform /packages/grafana-ui/src/utils/storybook @grafana/plugins-platform-frontend /packages/jaeger-ui-components/ @grafana/observability-squad /plugins-bundled @grafana/plugins-platform-frontend -/public @grafana/user-essentials +# public folder /public/app/core/components/TimePicker @grafana/grafana-bi-squad /public/app/features/canvas/ @grafana/grafana-edge-squad /public/app/features/dimensions/ @grafana/grafana-edge-squad diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 9214e4c52419f..bed91bb2edcc1 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -4,7 +4,9 @@ on: inputs: version: required: true - default: '7.x.x' + default: '8.x.x' +env: + YARN_ENABLE_IMMUTABLE_INSTALLS: false jobs: main: runs-on: ubuntu-latest @@ -33,12 +35,6 @@ jobs: 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: | @@ -53,9 +49,9 @@ jobs: repository: "grafana/grafana-github-actions" path: ./actions ref: main - - uses: actions/setup-node@v2.4.1 + - uses: actions/setup-node@v2.5.1 with: - node-version: '14' + node-version: '16' - name: Install Actions run: npm install --production --prefix ./actions - name: Run bump version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000..b99cd5f915c4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +--- +name: CI +on: + push: + branches: + - master + pull_request: + +jobs: + test: + name: Run Unit tests + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.x] + + steps: + - name: Checkout code + uses: percona-platform/checkout@v2 + - name: Run with Node 16 + uses: percona-platform/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Get Yarn cache directory + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn config get cacheFolder)" + + - name: Use Yarn cache + uses: percona-platform/cache@v2 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }} + + - name: Clean Yarn cache + run: yarn cache clean + + - name: Install dependencies + run: YARN_CHECKSUM_BEHAVIOR=update yarn install --immutable + + - name: Run tests + run: yarn run ci:test-percona-frontend diff --git a/.github/workflows/enable-docker-experimental.sh b/.github/workflows/enable-docker-experimental.sh new file mode 100755 index 0000000000000..72cc97af404bc --- /dev/null +++ b/.github/workflows/enable-docker-experimental.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -ex + +jq '.experimental = true' < /etc/docker/daemon.json > docker.json +mv docker.json /etc/docker/daemon.json +cat /etc/docker/daemon.json +service docker restart + +exec docker version -f '{{ .Server.Experimental }}' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1bf64485e25c5..89c45a1643b61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,47 +3,27 @@ name: publish_docs on: push: branches: - - main + - undefined # to prevent it from firing paths: - - 'docs/sources/**' - - 'packages/grafana-*/**' + - "docs/sources/**" 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 - - name: generate-packages-docs - uses: actions/setup-node@v2.4.1 - id: generate-docs - with: - node-version: '14' - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn config get cacheFolder)" - - uses: actions/cache@v2.1.6 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - yarn- - - run: yarn install --immutable - - 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 }}" + - uses: percona-platform/checkout@v2 # only platform actions allowed! + - name: publish-to-git + uses: ./.github/actions/gha-publish-to-git + 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/latest + - shell: bash + run: | + test -n "${{ steps.publish.outputs.commit_hash }}" + test -n "${{ steps.publish.outputs.working_directory }}" diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000000000..d5c65a85ac445 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,80 @@ +--- +name: UI Tests +on: + pull_request: + +jobs: + test: + name: Run UI tests + runs-on: ubuntu-20.04 + strategy: + matrix: + node-version: [16.x] + timeout-minutes: 50 + + steps: + - name: Checkout Grafana code + uses: percona-platform/checkout@v2 + with: + path: ./grafana + + - name: Run with Node ${{ matrix.node-version }} + uses: percona-platform/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Get Yarn cache directory + id: yarn-cache-dir-path + working-directory: ./grafana + run: echo "::set-output name=dir::$(yarn config get cacheFolder)" + + - name: Use Yarn cache + uses: percona-platform/cache@v2 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }} + + - name: Clean Yarn cache + run: yarn cache clean + + - name: Install dependencies for Grafana + working-directory: ./grafana + run: YARN_CHECKSUM_BEHAVIOR=update yarn install --immutable + + - name: Start Environment + working-directory: ./grafana + run: make run-local-env + + - name: Checkout UI tests in ${{ github.head_ref }} branch + uses: percona-platform/checkout@v2 + continue-on-error: true + id: branch_checkout + with: + token: ${{ secrets.ROBOT_TOKEN }} + ref: ${{ github.head_ref }} + repository: percona/pmm-ui-tests + path: ./pmm-ui-tests + + - name: Checkout UI tests in main branch + uses: percona-platform/checkout@v2 + if: ${{ steps.branch_checkout.outcome != 'success' }} + with: + token: ${{ secrets.ROBOT_TOKEN }} + ref: main + repository: percona/pmm-ui-tests + path: ./pmm-ui-tests + + - name: Install dependencies for UI tests + working-directory: ./pmm-ui-tests + run: npm install + + - name: Run UI tests + working-directory: ./pmm-ui-tests + run: npm run e2e:grafana-pr + + - name: Attaching artifacts + if: ${{ always() }} + uses: percona-platform/upload-artifact@v2 + with: + name: ui-tests-output + path: ./pmm-ui-tests/tests/output diff --git a/.gitignore b/.gitignore index 6a3f1a0ed5641..e9078e91bdbe1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ public/dist/tsconfig.tsbuildinfo /emails/dist /reports /e2e/tmp +/scripts/grafana-server/tmp vendor/ /docs/menu.yaml /requests @@ -137,6 +138,9 @@ compilation-stats.json !/e2e/**/screenshots/expected/* /e2e/**/videos/* +# grafana server +/scripts/grafana-server/server.log + # a11y tests /pa11y-ci-results.json /pa11y-ci-report @@ -149,3 +153,5 @@ compilation-stats.json # auto generated Go files *_gen.go + +deployment_tools_config.json diff --git a/.pa11yci-pr.conf.js b/.pa11yci-pr.conf.js index 816e6d8f6c5c8..308d95b6785cd 100644 --- a/.pa11yci-pr.conf.js +++ b/.pa11yci-pr.conf.js @@ -6,6 +6,7 @@ var config = { chromeLaunchConfig: { args: ['--no-sandbox'], }, + hideElements: '#updateVersion', }, urls: [ diff --git a/.pa11yci.conf.js b/.pa11yci.conf.js index 6b872fcf39077..0d7a9c1136162 100644 --- a/.pa11yci.conf.js +++ b/.pa11yci.conf.js @@ -6,6 +6,7 @@ var config = { chromeLaunchConfig: { args: ['--no-sandbox'], }, + hideElements: '#updateVersion', }, urls: [ diff --git a/.prettierignore b/.prettierignore index 9f8fae45f84ff..951a41382f537 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,7 @@ public/vendor/ vendor/ /data/ e2e/tmp +scripts/grafana-server/tmp public/build/ public/sass/*.generated.scss devenv/ diff --git a/.whitesource b/.whitesource new file mode 100644 index 0000000000000..0e40004795d88 --- /dev/null +++ b/.whitesource @@ -0,0 +1,24 @@ +{ + "scanSettings": { + "configMode": "AUTO", + "configExternalURL": "", + "projectToken": "", + "baseBranches": [], + "enableLicenseViolations": true + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff" + }, + "issueSettings": { + "minSeverityLevel": "NONE" + }, + "remediateSettings": { + "enableRenovate": false, + "dependencyDashboard": true, + "extends": [ + "github>whitesource/merge-confidence:beta", + "github>samq-ghdemo/renovate:demo" + ] + } +} diff --git a/.yarn/sdks/stylelint/bin/stylelint.js b/.yarn/sdks/stylelint/bin/stylelint.js deleted file mode 100755 index 04ffaf8eefa1d..0000000000000 --- a/.yarn/sdks/stylelint/bin/stylelint.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire, createRequireFromPath} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require stylelint/bin/stylelint.js - require(absPnpApiPath).setup(); - } -} - -// Defer to the real stylelint/bin/stylelint.js your application uses -module.exports = absRequire(`stylelint/bin/stylelint.js`); diff --git a/.yarn/sdks/stylelint/lib/index.js b/.yarn/sdks/stylelint/lib/index.js deleted file mode 100644 index 1b0b443f818d5..0000000000000 --- a/.yarn/sdks/stylelint/lib/index.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const {existsSync} = require(`fs`); -const {createRequire, createRequireFromPath} = require(`module`); -const {resolve} = require(`path`); - -const relPnpApiPath = "../../../../.pnp.cjs"; - -const absPnpApiPath = resolve(__dirname, relPnpApiPath); -const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); - -if (existsSync(absPnpApiPath)) { - if (!process.versions.pnp) { - // Setup the environment to be able to require stylelint/lib/index.js - require(absPnpApiPath).setup(); - } -} - -// Defer to the real stylelint/lib/index.js your application uses -module.exports = absRequire(`stylelint/lib/index.js`); diff --git a/.yarn/sdks/typescript/lib/tsserver.js b/.yarn/sdks/typescript/lib/tsserver.js index 71e35cf611945..b46100699e460 100644 --- a/.yarn/sdks/typescript/lib/tsserver.js +++ b/.yarn/sdks/typescript/lib/tsserver.js @@ -143,8 +143,9 @@ const moduleWrapper = tsserver => { let hostInfo = `unknown`; Object.assign(Session.prototype, { - onMessage(/** @type {string} */ message) { - const parsedMessage = JSON.parse(message) + onMessage(/** @type {string | object} */ message) { + const isStringMessage = typeof message === 'string'; + const parsedMessage = isStringMessage ? JSON.parse(message) : message; if ( parsedMessage != null && @@ -158,9 +159,14 @@ const moduleWrapper = tsserver => { } } - return originalOnMessage.call(this, JSON.stringify(parsedMessage, (key, value) => { - return typeof value === `string` ? fromEditorPath(value) : value; - })); + const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { + return typeof value === 'string' ? fromEditorPath(value) : value; + }); + + return originalOnMessage.call( + this, + isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) + ); }, send(/** @type {any} */ msg) { diff --git a/.yarn/sdks/typescript/lib/tsserverlibrary.js b/.yarn/sdks/typescript/lib/tsserverlibrary.js index 7a2d65ea22064..73a026534a024 100644 --- a/.yarn/sdks/typescript/lib/tsserverlibrary.js +++ b/.yarn/sdks/typescript/lib/tsserverlibrary.js @@ -143,8 +143,9 @@ const moduleWrapper = tsserver => { let hostInfo = `unknown`; Object.assign(Session.prototype, { - onMessage(/** @type {string} */ message) { - const parsedMessage = JSON.parse(message) + onMessage(/** @type {string | object} */ message) { + const isStringMessage = typeof message === 'string'; + const parsedMessage = isStringMessage ? JSON.parse(message) : message; if ( parsedMessage != null && @@ -158,9 +159,14 @@ const moduleWrapper = tsserver => { } } - return originalOnMessage.call(this, JSON.stringify(parsedMessage, (key, value) => { - return typeof value === `string` ? fromEditorPath(value) : value; - })); + const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { + return typeof value === 'string' ? fromEditorPath(value) : value; + }); + + return originalOnMessage.call( + this, + isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) + ); }, send(/** @type {any} */ msg) { diff --git a/CHANGELOG.md b/CHANGELOG.md index f059a93562920..8351ce8fd7cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,324 @@ + + +# 8.3.7 (2022-03-01) + +### Bug fixes + +- **Provisioning:** Ensure that the default value for orgID is set when provisioning datasources to be deleted. [#44244](https://github.com/grafana/grafana/pull/44244), [@filewalkwithme](https://github.com/filewalkwithme) + + + + +# 8.3.6 (2022-02-09) + +### Features and enhancements + +- **Cloud Monitoring:** Reduce request size when listing labels. [#44365](https://github.com/grafana/grafana/pull/44365), [@mtanda](https://github.com/mtanda) +- **Explore:** Show scalar data result in a table instead of graph. [#44362](https://github.com/grafana/grafana/pull/44362), [@tharun208](https://github.com/tharun208) +- **Snapshots:** Updates the default external snapshot server URL. [#44563](https://github.com/grafana/grafana/pull/44563), [@DanCech](https://github.com/DanCech) +- **Table:** Makes footer not overlap table content. [#44210](https://github.com/grafana/grafana/pull/44210), [@dprokop](https://github.com/dprokop) +- **Tempo:** Add request histogram to service graph datalink. [#44671](https://github.com/grafana/grafana/pull/44671), [@connorlindsey](https://github.com/connorlindsey) +- **Tempo:** Add time range to tempo search query behind a feature flag. [#43811](https://github.com/grafana/grafana/pull/43811), [@connorlindsey](https://github.com/connorlindsey) +- **Tempo:** Auto-clear results when changing query type. [#44390](https://github.com/grafana/grafana/pull/44390), [@connorlindsey](https://github.com/connorlindsey) +- **Tempo:** Display start time in search results as relative time. [#44568](https://github.com/grafana/grafana/pull/44568), [@tharun208](https://github.com/tharun208) + +### Bug fixes + +- **CloudMonitoring:** Fix resource labels in query editor. [#44550](https://github.com/grafana/grafana/pull/44550), [@iwysiu](https://github.com/iwysiu) +- **Cursor sync:** Apply the settings without saving the dashboard. [#44270](https://github.com/grafana/grafana/pull/44270), [@dprokop](https://github.com/dprokop) +- **LibraryPanels:** Fix for Error while cleaning library panels. [#45033](https://github.com/grafana/grafana/pull/45033), [@hugohaggmark](https://github.com/hugohaggmark) +- **Logs Panel:** fix timestamp parsing for string dates without timezone. [#44664](https://github.com/grafana/grafana/pull/44664), [@Elfo404](https://github.com/Elfo404) +- **Prometheus:** Fix some of the alerting queries that use reduce/math operation. [#44380](https://github.com/grafana/grafana/pull/44380), [@ivanahuckova](https://github.com/ivanahuckova) +- **TablePanel:** Fix ad-hoc variables not working on default datasources. [#44314](https://github.com/grafana/grafana/pull/44314), [@joshhunt](https://github.com/joshhunt) +- **Text Panel:** Fix alignment of elements. [#44313](https://github.com/grafana/grafana/pull/44313), [@ashharrison90](https://github.com/ashharrison90) +- **Variables:** Fix for constant variables in self referencing links. [#44631](https://github.com/grafana/grafana/pull/44631), [@hugohaggmark](https://github.com/hugohaggmark) + + + + +# 8.3.5 (2022-02-08) + +- **Security**: Fixes CVE-2022-21702. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21703. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21713. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) + + + + +# 8.3.4 (2022-01-17) + +### Features and enhancements + +- **Alerting:** Allow configuration of non-ready alertmanagers. [#43063](https://github.com/grafana/grafana/pull/43063), [@alexweav](https://github.com/alexweav) +- **Alerting:** Allow customization of Google chat message. [#43568](https://github.com/grafana/grafana/pull/43568), [@alexweav](https://github.com/alexweav) +- **Alerting:** Allow customization of Google chat message (#43568). [#43723](https://github.com/grafana/grafana/pull/43723), [@alexweav](https://github.com/alexweav) +- **AppPlugins:** Support app plugins with only default nav. [#43016](https://github.com/grafana/grafana/pull/43016), [@torkelo](https://github.com/torkelo) +- **InfluxDB:** InfluxQL: query editor: skip fields in metadata queries. [#42543](https://github.com/grafana/grafana/pull/42543), [@gabor](https://github.com/gabor) +- **Postgres/MySQL/MSSQL:** Cancel in-flight SQL query if user cancels query in grafana. [#43890](https://github.com/grafana/grafana/pull/43890), [@mdvictor](https://github.com/mdvictor) +- **Prometheus:** Forward oauth tokens after prometheus datasource migration. [#43686](https://github.com/grafana/grafana/pull/43686), [@MasslessParticle](https://github.com/MasslessParticle) + +### Bug fixes + +- **Azure Monitor:** Bug fix for variable interpolations in metrics dropdowns. [#43251](https://github.com/grafana/grafana/pull/43251), [@sarahzinger](https://github.com/sarahzinger) +- **Azure Monitor:** Improved error messages for variable queries. [#43213](https://github.com/grafana/grafana/pull/43213), [@sunker](https://github.com/sunker) +- **CloudMonitoring:** Fixes broken variable queries that use group bys. [#43914](https://github.com/grafana/grafana/pull/43914), [@sunker](https://github.com/sunker) +- **Configuration:** You can now see your expired API keys if you have no active ones. [#42452](https://github.com/grafana/grafana/pull/42452), [@ashharrison90](https://github.com/ashharrison90) +- **Elasticsearch:** Fix handling multiple datalinks for a single field. [#44029](https://github.com/grafana/grafana/pull/44029), [@Elfo404](https://github.com/Elfo404) +- **Export:** Fix error being thrown when exporting dashboards using query variables that reference the default datasource. [#44034](https://github.com/grafana/grafana/pull/44034), [@ashharrison90](https://github.com/ashharrison90) +- **ImportDashboard:** Fixes issue with importing dashboard and name ending up in uid. [#43451](https://github.com/grafana/grafana/pull/43451), [@torkelo](https://github.com/torkelo) +- **Login:** Page no longer overflows on mobile. [#43739](https://github.com/grafana/grafana/pull/43739), [@ashharrison90](https://github.com/ashharrison90) +- **Plugins:** Set backend metadata property for core plugins. [#43349](https://github.com/grafana/grafana/pull/43349), [@marefr](https://github.com/marefr) +- **Prometheus:** Fill missing steps with null values. [#43622](https://github.com/grafana/grafana/pull/43622), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Fix interpolation of $\_\_rate_interval variable. [#44035](https://github.com/grafana/grafana/pull/44035), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Interpolate variables with curly brackets syntax. [#42927](https://github.com/grafana/grafana/pull/42927), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Respect the http-method data source setting. [#42753](https://github.com/grafana/grafana/pull/42753), [@gabor](https://github.com/gabor) +- **Table:** Fixes issue with field config applied to wrong fields when hiding columns. [#43376](https://github.com/grafana/grafana/pull/43376), [@torkelo](https://github.com/torkelo) +- **Toolkit:** Fix bug with rootUrls not being properly parsed when signing a private plugin. [#43014](https://github.com/grafana/grafana/pull/43014), [@dessen-xu](https://github.com/dessen-xu) +- **Variables:** Fix so data source variables are added to adhoc configuration. [#43881](https://github.com/grafana/grafana/pull/43881), [@hugohaggmark](https://github.com/hugohaggmark) + +### Plugin development fixes & changes + +- **Toolkit:** Revert build config so tslib is bundled with plugins to prevent plugins from crashing. [#43556](https://github.com/grafana/grafana/pull/43556), [@mckn](https://github.com/mckn) + + + + +# 8.3.3 (2021-12-10) + +### Features and enhancements + +- **BarChart:** Use new data error view component to show actions in panel edit. [#42474](https://github.com/grafana/grafana/pull/42474), [@torkelo](https://github.com/torkelo) +- **CloudMonitor:** Iterate over pageToken for resources. [#42546](https://github.com/grafana/grafana/pull/42546), [@iwysiu](https://github.com/iwysiu) +- **Macaron:** Prevent WriteHeader invalid HTTP status code panic. [#42973](https://github.com/grafana/grafana/pull/42973), [@bergquist](https://github.com/bergquist) + +### Bug fixes + +- **AnnoListPanel:** Fix interpolation of variables in tags. [#42318](https://github.com/grafana/grafana/pull/42318), [@francoisdtm](https://github.com/francoisdtm) +- **CloudWatch:** Allow queries to have no dimensions specified. [#42800](https://github.com/grafana/grafana/pull/42800), [@sunker](https://github.com/sunker) +- **CloudWatch:** Fix broken queries for users migrating from 8.2.4/8.2.5 to 8.3.0. [#42611](https://github.com/grafana/grafana/pull/42611), [@sunker](https://github.com/sunker) +- **CloudWatch:** Make sure MatchExact flag gets the right value. [#42621](https://github.com/grafana/grafana/pull/42621), [@sunker](https://github.com/sunker) +- **Dashboards:** Fix so that empty folders can be deleted from the manage dashboards/folders page. [#42527](https://github.com/grafana/grafana/pull/42527), [@ashharrison90](https://github.com/ashharrison90) +- **InfluxDB:** Improve handling of metadata query errors in InfluxQL. [#42500](https://github.com/grafana/grafana/pull/42500), [@gabor](https://github.com/gabor) +- **Loki:** Fix adding of ad hoc filters for queries with parser and line_format expressions. [#42590](https://github.com/grafana/grafana/pull/42590), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Fix running of exemplar queries for non-histogram metrics. [#42749](https://github.com/grafana/grafana/pull/42749), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Interpolate template variables in interval. [#42637](https://github.com/grafana/grafana/pull/42637), [@ivanahuckova](https://github.com/ivanahuckova) +- **StateTimeline:** Fix toolitp not showing when for frames with multiple fields. [#42741](https://github.com/grafana/grafana/pull/42741), [@dprokop](https://github.com/dprokop) +- **TraceView:** Fix virtualized scrolling when trace view is opened in right pane in Explore. [#42480](https://github.com/grafana/grafana/pull/42480), [@autoric](https://github.com/autoric) +- **Variables:** Fix repeating panels for on time range changed variables. [#42828](https://github.com/grafana/grafana/pull/42828), [@hugohaggmark](https://github.com/hugohaggmark) +- **Variables:** Fix so queryparam option works for scoped variables. [#42742](https://github.com/grafana/grafana/pull/42742), [@hugohaggmark](https://github.com/hugohaggmark) + + + + +# 8.3.2 (2021-12-10) + +- **Security**: Fixes CVE-2021-43813 and CVE-2021-43815. For more information, see our [blog](https://grafana.com/blog/2021/12/10/grafana-8.3.2-and-7.5.12-released-with-moderate-severity-security-fix/ + + + + + +# 8.3.1 (2021-12-07) + +- **Security**: Fixes CVE-2021-43798. For more information, see our [blog](https://grafana.com/blog/2021/12/07/grafana-8.3.1-8.2.7-8.1.8-and-8.0.7-released-with-high-severity-security-fix/) + + + + + +# 8.3.0 (2021-11-30) + +### Features and enhancements + +- **Alerting:** Prevent folders from being deleted when they contain alerts. [#42307](https://github.com/grafana/grafana/pull/42307), [@peterholmberg](https://github.com/peterholmberg) +- **Alerting:** Show full preview value in tooltip. [#42445](https://github.com/grafana/grafana/pull/42445), [@peterholmberg](https://github.com/peterholmberg) +- **BarGauge:** Limit title width when name is really long. [#42346](https://github.com/grafana/grafana/pull/42346), [@torkelo](https://github.com/torkelo) +- **CloudMonitoring:** Avoid to escape regexps in filters. [#41961](https://github.com/grafana/grafana/pull/41961), [@andresmgot](https://github.com/andresmgot) +- **CloudWatch:** Add support for AWS Metric Insights. [#42487](https://github.com/grafana/grafana/pull/42487), [@sunker](https://github.com/sunker) +- **TooltipPlugin:** Remove other panels' shared tooltip in edit panel. [#42187](https://github.com/grafana/grafana/pull/42187), [@mdvictor](https://github.com/mdvictor) +- **Visualizations:** Limit y label width to 40% of visualization width. [#42350](https://github.com/grafana/grafana/pull/42350), [@torkelo](https://github.com/torkelo) + +### Bug fixes + +- **Alerting:** Clear alerting rule evaluation errors after intermittent failures. [#42386](https://github.com/grafana/grafana/pull/42386), [@gotjosh](https://github.com/gotjosh) +- **Alerting:** Fix refresh on legacy Alert List panel. [#42322](https://github.com/grafana/grafana/pull/42322), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard:** Fix queries for panels with non-integer widths. [#42420](https://github.com/grafana/grafana/pull/42420), [@gabor](https://github.com/gabor) +- **Explore:** Fix url update inconsistency. [#42288](https://github.com/grafana/grafana/pull/42288), [@gabor](https://github.com/gabor) +- **Prometheus:** Fix range variables interpolation for time ranges smaller than 1 second. [#42242](https://github.com/grafana/grafana/pull/42242), [@ivanahuckova](https://github.com/ivanahuckova) +- **ValueMappings:** Fixes issue with regex value mapping that only sets color. [#42311](https://github.com/grafana/grafana/pull/42311), [@torkelo](https://github.com/torkelo) + + + + +# 8.3.0-beta2 (2021-11-25) + +### Features and enhancements + +- **Alerting:** Create DatasourceError alert if evaluation returns error. [#41869](https://github.com/grafana/grafana/pull/41869), [@gerobinson](https://github.com/gerobinson) +- **Alerting:** Make Unified Alerting enabled by default for those who do not use legacy alerting. [#42200](https://github.com/grafana/grafana/pull/42200), [@armandgrillet](https://github.com/armandgrillet) +- **Alerting:** Support mute timings configuration through the api for the embedded alert manager. [#41533](https://github.com/grafana/grafana/pull/41533), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **CloudWatch:** Add missing AWS/Events metrics. [#42164](https://github.com/grafana/grafana/pull/42164), [@n2N8Z](https://github.com/n2N8Z) +- **Docs:** Add easier to find deprecation notices to certain data sources and to the changelog. [#41938](https://github.com/grafana/grafana/pull/41938), [@gabor](https://github.com/gabor) +- **Plugins Catalog:** Enable install controls based on the pluginAdminEnabled flag. [#41686](https://github.com/grafana/grafana/pull/41686), [@leventebalogh](https://github.com/leventebalogh) +- **Query caching:** Increase max_value_mb default to 10. (Enterprise) +- **Table:** Add space between values for the DefaultCell. [#42246](https://github.com/grafana/grafana/pull/42246), [@kirederik](https://github.com/kirederik) +- **Table:** Add space between values on JSONViewCell. [#42156](https://github.com/grafana/grafana/pull/42156), [@kirederik](https://github.com/kirederik) +- **Tracing:** Make query editors available in dashboard for Tempo and Zipkin. [#41974](https://github.com/grafana/grafana/pull/41974), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug fixes + +- **AccessControl:** Renamed `orgs` roles, removed `fixed:orgs:reader` introduced in beta1. [#42049](https://github.com/grafana/grafana/pull/42049), [@gamab](https://github.com/gamab) +- **Azure Monitor:** Add trap focus for modals in grafana/ui and other small a11y fixes for Azure Monitor. [#41449](https://github.com/grafana/grafana/pull/41449), [@sarahzinger](https://github.com/sarahzinger) +- **CodeEditor:** Prevent suggestions from being clipped. [#42120](https://github.com/grafana/grafana/pull/42120), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard:** Fix cache timeout persistence. [#42204](https://github.com/grafana/grafana/pull/42204), [@hugohaggmark](https://github.com/hugohaggmark) +- **Datasource:** Fix stable sort order of query responses. [#41868](https://github.com/grafana/grafana/pull/41868), [@marefr](https://github.com/marefr) +- **Explore:** Fix error in query history when removing last item. [#42179](https://github.com/grafana/grafana/pull/42179), [@gabor](https://github.com/gabor) +- **Logs:** Fix requesting of older logs when flipped order. [#41966](https://github.com/grafana/grafana/pull/41966), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Fix running of health check query based on access mode. [#42189](https://github.com/grafana/grafana/pull/42189), [@ivanahuckova](https://github.com/ivanahuckova) +- **TextPanel:** Fix suggestions for existing panels. [#42195](https://github.com/grafana/grafana/pull/42195), [@hugohaggmark](https://github.com/hugohaggmark) +- **Tracing:** Fix incorrect indentations due to reoccurring spanIDs. [#41919](https://github.com/grafana/grafana/pull/41919), [@ivanahuckova](https://github.com/ivanahuckova) +- **Tracing:** Show start time of trace with milliseconds precision. [#42132](https://github.com/grafana/grafana/pull/42132), [@ivanahuckova](https://github.com/ivanahuckova) +- **Variables:** Make renamed or missing variable section expandable. [#41964](https://github.com/grafana/grafana/pull/41964), [@hugohaggmark](https://github.com/hugohaggmark) + +### Breaking changes + +### Grafana 8 Alerting enabled by default for installations that do not use legacy alerting + +Starting with Grafana v8.3.0, if you have **not** explicitly disabled unified alerting and **do not** have legacy alerts set up you are automatically "migrated" to Grafana 8 Alerting. + +A migration **from legacy to Grafana 8 Alerting** will never incur a data loss, as the previous data is kept around for rollback purposes. However, going from **Grafana 8 Alerting to legacy alerting** will delete all the data created for Grafana 8 Alerting. It is recommended that you **backup your database** before attempting a migration between systems. + +If unclear, please verify the table below: + +| `[alerting][enabled]` | `[unified_alerting][enabled]` | With Existing Legacy Alerts | Result | +| --------------------- | ----------------------------- | --------------------------- | ------------------ | +| `true` | `true` | N/A | Error | +| `true` | `false` | N/A | Legacy Alerting | +| `true` | not set | Yes | Legacy Alerting | +| `true` | not set | No | Grafana 8 Alerting | +| not set | `true` | N/A | Grafana 8 Alerting | +| not set | `false` | N/A | Legacy Alerting | +| not set | not set | Yes | Legacy Alerting | +| not set | not set | No | Grafana 8 Alerting | +| `false` | `true` | N/A | Grafana 8 Alerting | +| `false` | `false` | N/A | Alerting disabled | +| `false` | not set | N/A | Grafana 8 Alerting | + +N/A in the "With Existing Legacy Alerts" column means that it does not matter if you have legacy alerts or not. +Issue [#42200](https://github.com/grafana/grafana/issues/42200) + +### Keep Last State for "If execution error or timeout" when upgrading to Grafana 8 alerting + +In Grafana 8.3.0-beta2 we changed how alert rules that use `Keep Last State` for `If execution error or timeout` are upgraded from Legacy Alerting to Grafana 8 alerting. In 8.3.0-beta1 and earlier, alert rules with `Keep Last State` for `If execution error or timeout` were changed to `Alerting` when upgrading from Legacy Alerting to Grafana 8 alerting. However, in 8.3.0-beta2 these alert rules are now upgraded to a new option called `Error`. With this option, on encountering an error evaluating an alert rule, Grafana creates a special alert called `DatasourceError` with the `rule_uid` and `ref_id` as labels and an annotation called `Error` with the error message. Issue [#41869](https://github.com/grafana/grafana/issues/41869) + +### Deprecations + +The access mode "browser" is deprecated in the following data sources and will be removed in a later release: + +- Prometheus +- InfluxDB +- Elasticsearch Issue [#41938](https://github.com/grafana/grafana/issues/41938) + +### Plugin development fixes & changes + +- **Select:** Select menus now properly scroll during keyboard navigation. [#41917](https://github.com/grafana/grafana/pull/41917), [@ashharrison90](https://github.com/ashharrison90) + + + + +# 8.3.0-beta1 (2021-11-18) + +### Features and enhancements + +- **AccessControl:** Apply fine-grained access control to licensing. (Enterprise) +- **Alerting:** Add UI for contact point testing with custom annotations and labels. [#40491](https://github.com/grafana/grafana/pull/40491), [@nathanrodman](https://github.com/nathanrodman) +- **Alerting:** Make alert state indicator in panel header work with Grafana 8 alerts. [#38713](https://github.com/grafana/grafana/pull/38713), [@domasx2](https://github.com/domasx2) +- **Alerting:** Option for Discord notifier to use webhook name. [#40463](https://github.com/grafana/grafana/pull/40463), [@Skyebold](https://github.com/Skyebold) +- **Annotations:** Deprecate AnnotationsSrv. [#39631](https://github.com/grafana/grafana/pull/39631), [@hugohaggmark](https://github.com/hugohaggmark) +- **Auditing:** Add audit logs for unified alerting endpoints. (Enterprise) +- **Auditing:** Add endpoints (plugins, datasources, library elements). (Enterprise) +- **Auth:** Omit all base64 paddings in JWT tokens for the JWT auth. [#35602](https://github.com/grafana/grafana/pull/35602), [@gillg](https://github.com/gillg) +- **Azure Monitor:** Clean up fields when editing Metrics. [#41762](https://github.com/grafana/grafana/pull/41762), [@andresmgot](https://github.com/andresmgot) +- **AzureMonitor:** Add new starter dashboards. [#39876](https://github.com/grafana/grafana/pull/39876), [@jcolladokuri](https://github.com/jcolladokuri) +- **AzureMonitor:** Add starter dashboard for app monitoring with Application Insights. [#40725](https://github.com/grafana/grafana/pull/40725), [@jcolladokuri](https://github.com/jcolladokuri) +- **Barchart/Time series:** Allow x axis label. [#41142](https://github.com/grafana/grafana/pull/41142), [@oscarkilhed](https://github.com/oscarkilhed) +- **CLI:** Improve error handling for installing plugins. [#41257](https://github.com/grafana/grafana/pull/41257), [@marefr](https://github.com/marefr) +- **CloudMonitoring:** Migrate to use backend plugin SDK contracts. [#38650](https://github.com/grafana/grafana/pull/38650), [@idafurjes](https://github.com/idafurjes) +- **CloudWatch Logs:** Add retry strategy for hitting max concurrent queries. [#39290](https://github.com/grafana/grafana/pull/39290), [@aocenas](https://github.com/aocenas) +- **CloudWatch:** Add AWS RoboMaker metrics and dimension. [#41450](https://github.com/grafana/grafana/pull/41450), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch:** Add AWS Transfer metrics and dimension. [#41168](https://github.com/grafana/grafana/pull/41168), [@ilyastoli](https://github.com/ilyastoli) +- **Dashboard:** replace datasource name with a reference object. [#33817](https://github.com/grafana/grafana/pull/33817), [@ryantxu](https://github.com/ryantxu) +- **Dashboards:** Show logs on time series when hovering. [#40110](https://github.com/grafana/grafana/pull/40110), [@ryantxu](https://github.com/ryantxu) +- **Elasticsearch:** Add support for Elasticsearch 8.0 (Beta). [#41729](https://github.com/grafana/grafana/pull/41729), [@Elfo404](https://github.com/Elfo404) +- **Elasticsearch:** Add time zone setting to Date Histogram aggregation. [#40882](https://github.com/grafana/grafana/pull/40882), [@Elfo404](https://github.com/Elfo404) +- **Elasticsearch:** Enable full range log volume histogram. [#41202](https://github.com/grafana/grafana/pull/41202), [@ifrost](https://github.com/ifrost) +- **Elasticsearch:** Full range logs volume. [#40700](https://github.com/grafana/grafana/pull/40700), [@ifrost](https://github.com/ifrost) +- **Explore:** Allow changing the graph type. [#40522](https://github.com/grafana/grafana/pull/40522), [@gabor](https://github.com/gabor) +- **Explore:** Show ANSI colors when highlighting matched words in the logs panel. [#40971](https://github.com/grafana/grafana/pull/40971), [@oliverfrye](https://github.com/oliverfrye) +- **Graph(old) panel:** Listen to events from Time series panel. [#41033](https://github.com/grafana/grafana/pull/41033), [@zoltanbedi](https://github.com/zoltanbedi) +- **Import:** Load gcom dashboards from URL. [#41799](https://github.com/grafana/grafana/pull/41799), [@ashharrison90](https://github.com/ashharrison90) +- **LibraryPanels:** Improves export and import of library panels between orgs. [#39214](https://github.com/grafana/grafana/pull/39214), [@hugohaggmark](https://github.com/hugohaggmark) +- **OAuth:** Support PKCE. [#39948](https://github.com/grafana/grafana/pull/39948), [@sakjur](https://github.com/sakjur) +- **Panel edit:** Overrides now highlight correctly when searching. [#41684](https://github.com/grafana/grafana/pull/41684), [@ashharrison90](https://github.com/ashharrison90) +- **PanelEdit:** Display drag indicators on draggable sections. [#41711](https://github.com/grafana/grafana/pull/41711), [@ashharrison90](https://github.com/ashharrison90) +- **Plugins:** Refactor Plugin Management. [#40477](https://github.com/grafana/grafana/pull/40477), [@wbrowne](https://github.com/wbrowne) +- **Prometheus:** Add custom query parameters when creating PromLink url. [#41213](https://github.com/grafana/grafana/pull/41213), [@Ian-Yy](https://github.com/Ian-Yy) +- **Prometheus:** Remove limits on metrics, labels, and values in Metrics Browser. [#40660](https://github.com/grafana/grafana/pull/40660), [@autoric](https://github.com/autoric) +- **StateTimeline:** Share cursor with rest of the panels. [#41038](https://github.com/grafana/grafana/pull/41038), [@zoltanbedi](https://github.com/zoltanbedi) +- **Tempo:** Add error details when json upload fails. [#41803](https://github.com/grafana/grafana/pull/41803), [@aocenas](https://github.com/aocenas) +- **Tempo:** Add filtering for service graph query. [#41162](https://github.com/grafana/grafana/pull/41162), [@aocenas](https://github.com/aocenas) +- **Tempo:** Add links to nodes in Service Graph pointing to Prometheus metrics. [#41135](https://github.com/grafana/grafana/pull/41135), [@aocenas](https://github.com/aocenas) +- **Time series/Bar chart panel:** Add ability to sort series via legend. [#40226](https://github.com/grafana/grafana/pull/40226), [@zoltanbedi](https://github.com/zoltanbedi) +- **TimeSeries:** Allow multiple axes for the same unit. [#41635](https://github.com/grafana/grafana/pull/41635), [@dprokop](https://github.com/dprokop) +- **TraceView:** Allow span links defined on dataFrame. [#40563](https://github.com/grafana/grafana/pull/40563), [@aocenas](https://github.com/aocenas) +- **Transformations:** Support a rows mode in labels to fields. [#41020](https://github.com/grafana/grafana/pull/41020), [@ryantxu](https://github.com/ryantxu) +- **ValueMappings:** Don't apply field config defaults to time fields. [#41132](https://github.com/grafana/grafana/pull/41132), [@torkelo](https://github.com/torkelo) +- **Variables:** Only update panels that are impacted by variable change. [#39420](https://github.com/grafana/grafana/pull/39420), [@hugohaggmark](https://github.com/hugohaggmark) + +### Bug fixes + +- **API:** Fix dashboard quota limit for imports. [#41495](https://github.com/grafana/grafana/pull/41495), [@yangkb09](https://github.com/yangkb09) +- **Alerting:** Fix rule editor issues with Azure Monitor data source. [#41317](https://github.com/grafana/grafana/pull/41317), [@domasx2](https://github.com/domasx2) +- **Azure monitor:** Make sure alert rule editor is not enabled when template variables are being used. [#41335](https://github.com/grafana/grafana/pull/41335), [@sunker](https://github.com/sunker) +- **CloudMonitoring:** Fix annotation queries. [#41529](https://github.com/grafana/grafana/pull/41529), [@sunker](https://github.com/sunker) +- **CodeEditor:** Trigger the latest getSuggestions() passed to CodeEditor. [#40544](https://github.com/grafana/grafana/pull/40544), [@DukeManh](https://github.com/DukeManh) +- **Dashboard:** Remove the current panel from the list of options in the Dashboard datasource. [#41826](https://github.com/grafana/grafana/pull/41826), [@ashharrison90](https://github.com/ashharrison90) +- **Encryption:** Fix decrypting secrets in alerting migration. [#41061](https://github.com/grafana/grafana/pull/41061), [@undef1nd](https://github.com/undef1nd) +- **InfluxDB:** Fix corner case where index is too large in ALIAS field. [#41562](https://github.com/grafana/grafana/pull/41562), [@gabor](https://github.com/gabor) +- **NavBar:** Order App plugins alphabetically. [#40078](https://github.com/grafana/grafana/pull/40078), [@ashharrison90](https://github.com/ashharrison90) +- **NodeGraph:** Fix zooming sensitivity on touchpads. [#40718](https://github.com/grafana/grafana/pull/40718), [@aocenas](https://github.com/aocenas) +- **Plugins:** Add OAuth pass-through logic to api/ds/query endpoint. [#41352](https://github.com/grafana/grafana/pull/41352), [@wbrowne](https://github.com/wbrowne) +- **Snapshots:** Fix panel inspector for snapshot data. [#41530](https://github.com/grafana/grafana/pull/41530), [@joshhunt](https://github.com/joshhunt) +- **Tempo:** Fix basic auth password reset on adding tag. [#41808](https://github.com/grafana/grafana/pull/41808), [@aocenas](https://github.com/aocenas) +- **ValueMapping:** Fixes issue with regex mappings. [#41515](https://github.com/grafana/grafana/pull/41515), [@mcdee](https://github.com/mcdee) + +### Plugin development fixes & changes + +- **grafana/ui:** Enable slider marks display. [#41275](https://github.com/grafana/grafana/pull/41275), [@dprokop](https://github.com/dprokop) + + + +# 8.2.7 (2021-12-07) + +- **Security**: Fixes CVE-2021-43798. For more information, see our [blog](https://grafana.com/blog/2021/12/07/grafana-8.3.1-8.2.7-8.1.8-and-8.0.7-released-with-high-severity-security-fix/) + + + + + +# 8.2.6 (2021-12-02) + +### Features and enhancements + +- **Security:** Upgrade Docker base image to Alpine 3.14.3. [#42061](https://github.com/grafana/grafana/pull/42061), [@dsotirakis](https://github.com/dsotirakis) +- **Security:** Upgrade Go to 1.17.2. [#42427](https://github.com/grafana/grafana/pull/42427), [@idafurjes](https://github.com/idafurjes) + +### Bug fixes + +- **TimeSeries:** Fix fillBelowTo wrongly affecting fills of unrelated series. [#41998](https://github.com/grafana/grafana/pull/41998), [@leeoniya](https://github.com/leeoniya) + + + # 8.2.5 (2021-11-18) @@ -5,6 +326,7 @@ ### Bug fixes - **Alerting:** Fix a bug where the metric in the evaluation string was not correctly populated. [#41731](https://github.com/grafana/grafana/pull/41731), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **Alerting:** Fix no data behaviour in Legacy Alerting for alert rules using the AND operator. [#41305](https://github.com/grafana/grafana/pull/41305), [@gerobinson](https://github.com/gerobinson) - **CloudMonitoring:** Ignore min and max aggregation in MQL queries. [#41302](https://github.com/grafana/grafana/pull/41302), [@sunker](https://github.com/sunker) - **Dashboards:** 'Copy' is no longer added to new dashboard titles. [#41344](https://github.com/grafana/grafana/pull/41344), [@joshhunt](https://github.com/joshhunt) - **DataProxy:** Fix overriding response body when response is a WebSocket upgrade. [#41364](https://github.com/grafana/grafana/pull/41364), [@marefr](https://github.com/marefr) @@ -15,6 +337,14 @@ - **Tempo:** Fix validation of float durations. [#41400](https://github.com/grafana/grafana/pull/41400), [@ivanahuckova](https://github.com/ivanahuckova) - **Tracing:** Correct tags for each span are shown. [#41473](https://github.com/grafana/grafana/pull/41473), [@ivanahuckova](https://github.com/ivanahuckova) +### Breaking changes + +### Fix No Data behaviour in Legacy Alerting + +In Grafana 8.2.5 and later, this change fixes a bug in the evaluation of alert rules when using the AND operator to compare two or more conditions. In Grafana 8.2.4 and earlier such alert rules would evaluate to `OK` if at least one, but not all, conditions returned no data. This change fixes that bug such that in Grafana 8.2.5 these alert rules now evaluate to `No Data`. + +If an alert should evaluate to `OK` when one or all conditions return `No Data` then this can be done via changing `If no data or all values are null` to `OK`. However, this will not preserve the old behaviour in 8.2.4 where an alert will be `OK` if at least one, but not all, conditions return no data and then `No Data` if all conditions return `No Data`. Issue [#41305](https://github.com/grafana/grafana/issues/41305) + @@ -253,6 +583,14 @@ Panel queries and/or annotation queries that used more than one statistic will b + + +# 8.1.8 (2021-12-07) + +- **Security**: Fixes CVE-2021-43798. For more information, see our [blog](https://grafana.com/blog/2021/12/07/grafana-8.3.1-8.2.7-8.1.8-and-8.0.7-released-with-high-severity-security-fix/) + + + # 8.1.7 (2021-10-06) @@ -527,6 +865,14 @@ Issue [#33879](https://github.com/grafana/grafana/issues/33879) + + +# 8.0.7 (2021-12-07) + +- **Security**: Fixes CVE-2021-43798. For more information, see our [blog](https://grafana.com/blog/2021/12/07/grafana-8.3.1-8.2.7-8.1.8-and-8.0.7-released-with-high-severity-security-fix/) + + + # 8.0.6 (2021-07-14) @@ -958,6 +1304,24 @@ Issue [#33352](https://github.com/grafana/grafana/issues/33352) - **AGPL License:** Update license from Apache 2.0 to the GNU Affero General Public License (AGPL). [#33184](https://github.com/grafana/grafana/pull/33184) + + +# 7.5.15 (2022-02-08) + +- **Security**: Fixes CVE-2022-21702. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21703. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21713. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) + + + + +# 7.5.13 (2022-01-18) + +### Bug fixes + +- **[v7.5.x] Alerting:** Fix NoDataFound for alert rules using AND operator (#41305). [#44066](https://github.com/grafana/grafana/pull/44066), [@armandgrillet](https://github.com/armandgrillet) + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa22f27da04c5..eb5c04ab8e996 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,6 @@ Before we can accept your pull request, you need to [sign our CLA](https://grafa ## Where do I go from here? - Set up your [development environment](contribute/developer-guide.md). -- Learn how to [contribute documentation](contribute/documentation.md). +- Learn how to [contribute documentation](contribute/README.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. +- Look through the resources in the [contribute](contribute) folder. diff --git a/Dockerfile b/Dockerfile index 82441d666ed52..152b26ab06c87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16-alpine3.14 as js-builder +FROM node:16-alpine3.15 as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 @@ -20,14 +20,13 @@ COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.17.0-alpine3.14 as go-builder +FROM golang:1.17.8-alpine3.15 as go-builder RUN apk add --no-cache gcc g++ make WORKDIR /grafana COPY go.mod go.sum embed.go Makefile build.go package.json ./ -COPY ./local/* ./local/ COPY cue cue COPY packages/grafana-schema packages/grafana-schema COPY public/app/plugins public/app/plugins @@ -40,7 +39,7 @@ RUN go mod verify RUN make build-go # Final stage -FROM alpine:3.14.2 +FROM alpine:3.15 LABEL maintainer="Grafana team " diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index b77ece310c594..4d7252de6da10 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -1,4 +1,4 @@ -FROM node:16-alpine3.14 as js-builder +FROM node:16-alpine3.15 as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 @@ -21,7 +21,7 @@ COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.17.0 AS go-builder +FROM golang:1.17.8 AS go-builder WORKDIR /src/grafana diff --git a/Makefile b/Makefile index 00510a14aa401..0a4003ad8410a 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ deps: deps-js ## Install all dependencies. node_modules: package.json yarn.lock ## Install node modules. @echo "install frontend dependencies" - YARN_ENABLE_PROGRESS_BARS=false yarn install --immutable + YARN_CHECKSUM_BEHAVIOR=update YARN_ENABLE_PROGRESS_BARS=false yarn install --immutable ##@ Building @@ -48,7 +48,7 @@ build-cli: ## Build Grafana CLI application. build-js: ## Build frontend assets. @echo "build frontend" - yarn run build + yarn run build-max-memory yarn run plugins:build-bundled build: build-go build-js ## Build backend and frontend. @@ -63,6 +63,10 @@ run: scripts/go/bin/bra ## Build and run web server on filesystem changes. run-frontend: deps-js ## Fetch js dependencies and watch frontend for rebuild yarn start +run-local-env: ## Start local frontend with pmm-server:dev-latest + yarn dev + docker-compose up -d + ##@ Testing test-go: ## Run tests for backend. @@ -158,7 +162,7 @@ gen-ts: # you modify starlark files. drone: $(DRONE) $(DRONE) starlark --format - $(DRONE) lint .drone.yml + $(DRONE) lint .drone.yml --trusted $(DRONE) --server https://drone.grafana.net sign --save grafana/grafana help: ## Display this help. diff --git a/conf/defaults.ini b/conf/defaults.ini index 5022515212cbb..9681f86186748 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -228,9 +228,12 @@ admin_password = admin # used for signing secret_key = SW2YcwTIb9zpOOhoPsMm -# key provider used for envelope encryption, default to static value specified by secret_key +# current key provider used for envelope encryption, default to static value specified by secret_key encryption_provider = secretKey +# list of configured key providers, space separated (Enterprise only): e.g., awskms.v1 azurekv.v1 +available_encryption_providers = + # disable gravatar profile images disable_gravatar = false @@ -285,8 +288,8 @@ content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-in [snapshots] # snapshot sharing options external_enabled = true -external_snapshot_url = https://snapshots-origin.raintank.io -external_snapshot_name = Publish to snapshot.raintank.io +external_snapshot_url = https://snapshots.raintank.io +external_snapshot_name = Publish to snapshots.raintank.io # Set to true to enable this Grafana instance act as an external snapshot server and allow unauthenticated requests for # creating and deleting snapshots. @@ -735,8 +738,8 @@ global_alert_rule = -1 #################################### Unified Alerting #################### [unified_alerting] -# Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed. -enabled = false +# Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed when switching. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details. +enabled = # Comma-separated list of organization IDs for which to disable unified alerting. Only supported if unified alerting is enabled. disabled_orgs = @@ -790,8 +793,8 @@ min_interval = 10s #################################### Alerting ############################ [alerting] -# Disable legacy alerting engine & UI features -enabled = true +# Enable the legacy alerting sub-system and interface. If Unified Alerting is already enabled and you try to go back to legacy alerting, all data that is part of Unified Alerting will be deleted. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details. +enabled = # Makes it possible to turn off alert execution but alerting UI is visible execute_alerts = true @@ -968,7 +971,7 @@ enable_alpha = false app_tls_skip_verify_insecure = false # Enter a comma-separated list of plugin identifiers to identify plugins to load even if they are unsigned. Plugins with modified signatures are never loaded. allow_loading_unsigned_plugins = -# Enable or disable installing plugins directly from within Grafana. +# Enable or disable installing / uninstalling / updating plugins directly from within Grafana. plugin_admin_enabled = true plugin_admin_external_manage_enabled = false plugin_catalog_url = https://grafana.com/grafana/plugins/ @@ -1040,12 +1043,14 @@ rendering_chrome_bin = # Mode 'reusable' will have one browser instance and will create a new incognito page on each request. rendering_mode = -# When rendering_mode = clustered you can instruct how many browsers or incognito pages can execute concurrently. Default is 'browser' +# When rendering_mode = clustered, you can instruct how many browsers or incognito pages can execute concurrently. Default is 'browser' # and will cluster using browser instances. # Mode 'context' will cluster using incognito pages. rendering_clustering_mode = -# When rendering_mode = clustered you can define maximum number of browser instances/incognito pages that can execute concurrently.. +# When rendering_mode = clustered, you can define the maximum number of browser instances/incognito pages that can execute concurrently. Default is '5'. rendering_clustering_max_concurrency = +# When rendering_mode = clustered, you can specify the duration a rendering request can take before it will time out. Default is `30` seconds. +rendering_clustering_timeout = # Limit the maximum viewport width, height and device scale factor that can be requested. rendering_viewport_max_width = diff --git a/conf/sample.ini b/conf/sample.ini index 87089311c7ed1..93dd700514b20 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -222,9 +222,12 @@ # used for signing ;secret_key = SW2YcwTIb9zpOOhoPsMm -# key provider used for envelope encryption, default to static value specified by secret_key +# current key provider used for envelope encryption, default to static value specified by secret_key ;encryption_provider = secretKey +# list of configured key providers, space separated (Enterprise only): e.g., awskms.v1 azurekv.v1 +;available_encryption_providers = + # disable gravatar profile images ;disable_gravatar = false @@ -279,8 +282,8 @@ [snapshots] # snapshot sharing options ;external_enabled = true -;external_snapshot_url = https://snapshots-origin.raintank.io -;external_snapshot_name = Publish to snapshot.raintank.io +;external_snapshot_url = https://snapshots.raintank.io +;external_snapshot_name = Publish to snapshots.raintank.io # Set to true to enable this Grafana instance act as an external snapshot server and allow unauthenticated requests for # creating and deleting snapshots. @@ -713,7 +716,7 @@ #################################### Unified Alerting #################### [unified_alerting] #Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed.``` -;enabled = false +;enabled = true # Comma-separated list of organization IDs for which to disable unified alerting. Only supported if unified alerting is enabled. ;disabled_orgs = @@ -768,7 +771,7 @@ #################################### Alerting ############################ [alerting] # Disable legacy alerting engine & UI features -;enabled = true +;enabled = false # Makes it possible to turn off alert execution but alerting UI is visible ;execute_alerts = true @@ -941,7 +944,7 @@ ;app_tls_skip_verify_insecure = false # Enter a comma-separated list of plugin identifiers to identify plugins to load even if they are unsigned. Plugins with modified signatures are never loaded. ;allow_loading_unsigned_plugins = -# Enable or disable installing plugins directly from within Grafana. +# Enable or disable installing / uninstalling / updating plugins directly from within Grafana. ;plugin_admin_enabled = false ;plugin_admin_external_manage_enabled = false ;plugin_catalog_url = https://grafana.com/grafana/plugins/ @@ -1012,12 +1015,14 @@ # Mode 'reusable' will have one browser instance and will create a new incognito page on each request. ;rendering_mode = -# When rendering_mode = clustered you can instruct how many browsers or incognito pages can execute concurrently. Default is 'browser' +# When rendering_mode = clustered, you can instruct how many browsers or incognito pages can execute concurrently. Default is 'browser' # and will cluster using browser instances. # Mode 'context' will cluster using incognito pages. ;rendering_clustering_mode = -# When rendering_mode = clustered you can define maximum number of browser instances/incognito pages that can execute concurrently.. +# When rendering_mode = clustered, you can define the maximum number of browser instances/incognito pages that can execute concurrently. Default is '5'. ;rendering_clustering_max_concurrency = +# When rendering_mode = clustered, you can specify the duration a rendering request can take before it will time out. Default is `30` seconds. +;rendering_clustering_timeout = # Limit the maximum viewport width, height and device scale factor that can be requested. ;rendering_viewport_max_width = diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 98bd113929d84..1ea45276b16d8 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -81,7 +81,23 @@ When you log in for the first time, Grafana asks you to change your password. The Grafana backend includes SQLite which requires GCC to compile. So in order to compile Grafana on Windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). Eventually, if you use [Scoop](https://scoop.sh), you can install GCC through that. -You can simply build the back-end as follows: `go run build.go build`. The Grafana binaries will be in bin\\windows-amd64. +You can build the back-end as follows: + +1. Follow the [instructions](https://github.com/google/wire#installing) to install the Wire tool. +2. Generate code using Wire: + +``` +# Normally Wire tool installed at $GOPATH/bin/wire.exe + gen -tags oss ./pkg/server ./pkg/cmd/grafana-cli/runner +``` + +3. Build the Grafana binaries: + +``` +go run build.go build +``` + +The Grafana binaries will be in bin\\windows-amd64. Alternately, if you wish to use the `make` command, install [Make for Windows](http://gnuwin32.sourceforge.net/packages/make.htm) and use it in a Unix shell (f.ex. Git Bash). ## Test Grafana diff --git a/contribute/style-guides/e2e-core.md b/contribute/style-guides/e2e-core.md index 813ec9fe57453..fb2c24e0bfc8c 100644 --- a/contribute/style-guides/e2e-core.md +++ b/contribute/style-guides/e2e-core.md @@ -16,8 +16,8 @@ BASE_URL=http://172.0.10.2:3333 yarn e2e The above commands use some utils scripts under [_\/e2e_](../../e2e) that can also be used for more control. -- `./e2e/start-server` This creates a fresh new grafana server working dir, setup's config and starts the server. It will also kill any previously started server that is still running using pid file at _\/e2e/tmp/pid_. -- `./e2e/wait-for-grafana` waits for `$HOST` and `$PORT` to be available. Per default localhost and 3001. +- `./scripts/grafana-server/start-server` This creates a fresh new grafana server working dir, setup's config and starts the server. It will also kill any previously started server that is still running using pid file at _\/scripts/grafana-server/tmp/pid_. +- `./scripts/grafana-server/wait-for-grafana` waits for `$HOST` and `$PORT` to be available. Per default localhost and 3001. - `./e2e/run-suite ` Starts cypress in different modes. ## Test suites diff --git a/devenv/dev-dashboards/datasource-testdata/new_features_in_v74.json b/devenv/dev-dashboards/datasource-testdata/new_features_in_v74.json index b3116e4ed63ec..3e8af852650e2 100644 --- a/devenv/dev-dashboards/datasource-testdata/new_features_in_v74.json +++ b/devenv/dev-dashboards/datasource-testdata/new_features_in_v74.json @@ -1991,7 +1991,7 @@ "axisLabel": "", "axisPlacement": "auto", "drawStyle": "line", - "fillOpacity": 29, + "fillOpacity": 0, "gradientMode": { "label": "None", "value": "none" @@ -2054,6 +2054,18 @@ "value": 0 } ] + }, + { + "matcher": { + "id": "byName", + "options": "value" + }, + "properties": [ + { + "id": "custom.fillOpacity", + "value": 0 + } + ] } ] }, diff --git a/devenv/dev-dashboards/panel-candlestick/candlestick.json b/devenv/dev-dashboards/panel-candlestick/candlestick.json index 5b171e93142f4..6cfaf6d95e309 100644 --- a/devenv/dev-dashboards/panel-candlestick/candlestick.json +++ b/devenv/dev-dashboards/panel-candlestick/candlestick.json @@ -1,522 +1,481 @@ { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 2, - "id": 319, - "links": [], - "liveNow": false, - "panels": [ + "annotations": { + "list": [ { - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 2, + "links": [], + "liveNow": false, + "panels": [ + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineStyle": { - "dash": [ - 10, - 10 - ], - "fill": "dash" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "lineInterpolation": "linear", + "lineStyle": { + "dash": [ + 10, + 10 + ], + "fill": "dash" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" }, - "unit": "currencyUSD" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "sma" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "orange", - "mode": "fixed" - } - }, - { - "id": "custom.lineWidth", - "value": 5 - }, - { - "id": "custom.lineStyle", - "value": { - "dash": [ - 0, - 20 - ], - "fill": "dot" - } - } - ] + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" }, - { - "matcher": { - "id": "byName", - "options": "bolup" - }, - "properties": [ - { - "id": "custom.fillBelowTo", - "value": "boldn" - }, - { - "id": "custom.fillOpacity", - "value": 8 - }, - { - "id": "color", - "value": { - "fixedColor": "blue", - "mode": "fixed" - } - } - ] + "thresholdsStyle": { + "mode": "off" } - ] - }, - "gridPos": { - "h": 16, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 2, - "options": { - "colorStrategy": "open-close", - "colors": { - "down": "red", - "up": "green" }, - "fields": {}, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] }, - "mode": "candles+volume", - "candleStyle": "candles" + "unit": "currencyUSD" }, - "targets": [ + "overrides": [ { - "csvFileName": "ohlc_dogecoin.csv", - "refId": "A", - "scenarioId": "csv_file" - } - ], - "title": "Price & Volume", - "type": "candlestick" - }, - { - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "matcher": { + "id": "byName", + "options": "sma" }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + { + "id": "custom.lineWidth", + "value": 5 }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 0, + 20 + ], + "fill": "dot" } - ] - }, - "unit": "currencyUSD" - }, - "overrides": [] - }, - "gridPos": { - "h": 20, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 7, - "options": { - "colorStrategy": "close-close", - "colors": { - "down": "red", - "up": "green" - }, - "fields": {}, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" + } + ] }, - "mode": "candles", - "candleStyle": "candles" - }, - "targets": [ - { - "csvFileName": "ohlc_dogecoin.csv", - "refId": "A", - "scenarioId": "csv_file" - } - ], - "title": "Price Only, Hollow Candles", - "transformations": [ { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "time", - "open", - "high", - "low", - "close" - ] - } - } - } - ], - "type": "candlestick" - }, - { - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "matcher": { + "id": "byName", + "options": "bolup" }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false + "properties": [ + { + "id": "custom.fillBelowTo", + "value": "boldn" }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + { + "id": "custom.fillOpacity", + "value": 8 }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } } + ] + } + ] + }, + "gridPos": { + "h": 16, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "candleStyle": "candles", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "green" + }, + "fields": {}, + "includeAllFields": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "mode": "candles+volume" + }, + "targets": [ + { + "csvFileName": "ohlc_dogecoin.csv", + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "Price & Volume", + "type": "candlestick" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" }, - "unit": "currencyUSD" + "thresholdsStyle": { + "mode": "off" + } }, - "overrides": [] + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "currencyUSD" }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 16 + "overrides": [] + }, + "gridPos": { + "h": 20, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 7, + "options": { + "candleStyle": "candles", + "colorStrategy": "close-close", + "colors": { + "down": "red", + "up": "green" }, - "id": 6, - "options": { - "colorStrategy": "open-close", - "colors": { - "down": "red", - "up": "blue" - }, - "fields": { }, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "mode": "candles", - "candleStyle": "ohlcbars" + "fields": {}, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" }, - "targets": [ - { - "csvFileName": "ohlc_dogecoin.csv", - "refId": "A", - "scenarioId": "csv_file" - } - ], - "title": "Price Only, OHLC Bars", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "time", - "open", - "high", - "low", - "close" - ] - } - } - } - ], - "type": "candlestick" + "mode": "candles" }, - { - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "targets": [ + { + "csvFileName": "ohlc_dogecoin.csv", + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "Price Only, Hollow Candles", + "transformations": [], + "type": "candlestick" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 100, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" }, - "unit": "currencyUSD" + "thresholdsStyle": { + "mode": "off" + } }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "open" + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": true, - "tooltip": true, - "viz": true - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "close" - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": true, - "tooltip": true, - "viz": true - } - } - ] - } - ] + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "currencyUSD" }, - "gridPos": { - "h": 6, - "w": 12, - "x": 12, - "y": 20 + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 6, + "options": { + "candleStyle": "ohlcbars", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "blue" }, - "id": 4, - "options": { - "colorStrategy": "open-close", - "colors": { - "down": "red", - "up": "yellow" + "fields": {}, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "mode": "candles" + }, + "targets": [ + { + "csvFileName": "ohlc_dogecoin.csv", + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "Price Only, OHLC Bars", + "transformations": [], + "type": "candlestick" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" }, - "fields": {}, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] }, - "mode": "volume", - "candleStyle": "candles" + "unit": "currencyUSD" }, - "targets": [ + "overrides": [ { - "csvFileName": "ohlc_dogecoin.csv", - "refId": "A", - "scenarioId": "csv_file" - } - ], - "title": "Volume Only, Alt Colors, 100% Opacity", - "transformations": [ + "matcher": { + "id": "byName", + "options": "open" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": true + } + } + ] + }, { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "time", - "volume", - "open", - "close" - ] + "matcher": { + "id": "byName", + "options": "close" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": true + } } - } + ] } - ], - "type": "candlestick" - } - ], - "refresh": false, - "schemaVersion": 33, - "style": "dark", - "tags": [ - "gdev", - "panel-tests", - "graph-ng" - ], - "templating": { - "list": [] - }, - "time": { - "from": "2021-07-13T22:13:30.740Z", - "to": "2021-07-13T22:46:18.921Z" - }, - "timepicker": {}, - "timezone": "", - "title": "Candlestick", - "uid": "MP-Di9F7k", - "version": 7, - "weekStart": "" - } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 4, + "options": { + "candleStyle": "candles", + "colorStrategy": "open-close", + "colors": { + "down": "red", + "up": "yellow" + }, + "fields": {}, + "includeAllFields": false, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "mode": "volume" + }, + "targets": [ + { + "csvFileName": "ohlc_dogecoin.csv", + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "Volume Only, Alt Colors, 100% Opacity", + "transformations": [], + "type": "candlestick" + } + ], + "refresh": false, + "schemaVersion": 33, + "style": "dark", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "templating": { + "list": [] + }, + "time": { + "from": "2021-07-13T22:13:30.740Z", + "to": "2021-07-13T22:46:18.921Z" + }, + "timepicker": {}, + "timezone": "", + "title": "Candlestick", + "uid": "MP-Di9F7k", + "version": 2, + "weekStart": "" +} diff --git a/devenv/dev-dashboards/panel-geomap/geomap_multi-layers.json b/devenv/dev-dashboards/panel-geomap/geomap_multi-layers.json new file mode 100644 index 0000000000000..7a00c1f3fcfc7 --- /dev/null +++ b/devenv/dev-dashboards/panel-geomap/geomap_multi-layers.json @@ -0,0 +1,436 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 157, + "links": [], + "liveNow": false, + "panels": [ + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 20 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 22, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "basemap": { + "config": {}, + "name": "Layer 0", + "type": "default" + }, + "controls": { + "mouseWheelZoom": true, + "showAttribution": true, + "showDebug": false, + "showScale": false, + "showZoom": true + }, + "layers": [ + { + "config": { + "rules": [ + { + "check": { + "operation": "eq", + "property": "name", + "value": "Greenland" + }, + "style": { + "color": { + "fixed": "light-blue" + }, + "opacity": 0.4, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 0, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + { + "check": { + "operation": "eq", + "property": "name", + "value": "Antarctica" + }, + "style": { + "color": { + "fixed": "#FCE2DE" + }, + "opacity": 0.4, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 0, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + { + "check": { + "operation": "eq", + "property": "name", + "value": "Canada" + }, + "style": { + "color": { + "fixed": "#37872D" + }, + "opacity": 0.4, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 0, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + { + "check": { + "operation": "eq", + "property": "name", + "value": "Mexico" + }, + "style": { + "color": { + "fixed": "#1F60C4" + }, + "opacity": 0.4, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 0, + "textAlign": "center", + "textBaseline": "middle" + } + } + } + ], + "src": "public/maps/countries.geojson", + "style": { + "color": { + "fixed": "dark-green" + }, + "opacity": 0.1, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 0, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + "name": "Countries", + "type": "geojson" + }, + { + "config": { + "showLegend": true, + "style": { + "color": { + "fixed": "dark-blue" + }, + "opacity": 0.4, + "rotation": { + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 5, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/square.svg", + "mode": "fixed" + }, + "text": { + "field": "Count", + "fixed": "", + "mode": "field" + }, + "textConfig": { + "fontSize": 16, + "offsetX": 10, + "offsetY": 10, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + "location": { + "mode": "auto" + }, + "name": "Flights", + "type": "markers" + } + ], + "view": { + "id": "coords", + "lat": 42.826559, + "lon": -96.868893, + "zoom": 3.58 + } + }, + "pluginVersion": "8.3.0-pre", + "targets": [ + { + "csvFileName": "flight_info_by_state.csv", + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "Multi layers", + "type": "geomap" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 20 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 22, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "basemap": { + "config": {}, + "name": "Layer 0", + "type": "default" + }, + "controls": { + "mouseWheelZoom": true, + "showAttribution": true, + "showDebug": false, + "showScale": false, + "showZoom": true + }, + "layers": [ + { + "config": { + "showLegend": true, + "style": { + "color": { + "fixed": "dark-green" + }, + "opacity": 0.7, + "rotation": { + "field": "Lng", + "fixed": 0, + "max": 360, + "min": -360, + "mode": "mod" + }, + "size": { + "fixed": 10, + "max": 15, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/plane.svg", + "mode": "fixed" + }, + "text": { + "field": "Lng", + "fixed": "", + "mode": "field" + }, + "textConfig": { + "fontSize": 12, + "offsetX": 0, + "offsetY": 18, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + "name": "Layer 2", + "type": "markers" + } + ], + "view": { + "id": "coords", + "lat": 42.826559, + "lon": -96.868893, + "zoom": 3.58 + } + }, + "pluginVersion": "8.3.0-pre", + "targets": [ + { + "csvFileName": "flight_info_by_state.csv", + "refId": "A", + "scenarioId": "csv_file" + } + ], + "title": "Markers", + "type": "geomap" + } + ], + "schemaVersion": 33, + "style": "dark", + "tags": [ + "gdev", + "geomap", + "panel-tests" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Panel Tests - Geomap Multi Layers", + "uid": "2jFpEvp7z", + "version": 8, + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-graph/graph-ng.json b/devenv/dev-dashboards/panel-graph/graph-ng.json index b5a78989c28cc..c2c39ca6659d7 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng.json @@ -1346,7 +1346,7 @@ "axisPlacement": "auto", "drawStyle": "line", "fillGradient": "hue", - "fillOpacity": 25, + "fillOpacity": 0, "hideFrom": { "graph": false, "legend": false, @@ -1426,6 +1426,10 @@ "fixedColor": "blue", "mode": "fixed" } + }, + { + "id": "custom.fillOpacity", + "value": 0 } ] } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000..137e192488e9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +--- +version: "3" +services: + pmm-server: + container_name: pmm-server + image: perconalab/pmm-server:dev-latest + volumes: + - "../grafana/public:/usr/share/grafana/public" + ports: + - 80:80 + - 443:443 + environment: + - PMM_DEBUG=1 + - PERCONA_PORTAL_URL=https://portal-dev.percona.com + - PERCONA_TEST_PLATFORM_ADDRESS=https://check-dev.percona.com:443 + - PERCONA_TEST_PLATFORM_PUBLIC_KEY=RWTkF7Snv08FCboTne4djQfN5qbrLfAjb8SY3/wwEP+X5nUrkxCEvUDJ + + mysql: + image: percona:5.7.30 + container_name: pmm-agent_mysql + command: > + --sql-mode="ANSI_QUOTES" + --performance-schema --innodb_monitor_enable=all + --slow_query_log --log_slow_rate_limit=1 --log_slow_admin_statements --log_slow_slave_statements --slow_query_log_file=/mysql/slowlogs/slow.log --long_query_time=0 + ports: + - "3306:3306" + environment: + - MYSQL_ROOT_PASSWORD=ps + - MYSQL_USER=pmm-agent + - MYSQL_PASSWORD=pmm%*&agent-password + - UMASK=0777 # for slowlog file + volumes: + - ./testdata/mysql:/mysql diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 2939b94e44899..49469206a677b 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -565,11 +565,11 @@ Set to `false` to disable external snapshot publish endpoint (default `true`). ### external_snapshot_url -Set root URL to a Grafana instance where you want to publish external snapshots (defaults to https://snapshots-origin.raintank.io). +Set root URL to a Grafana instance where you want to publish external snapshots (defaults to https://snapshots.raintank.io). ### external_snapshot_name -Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io`. +Set name for external snapshot button. Defaults to `Publish to snapshots.raintank.io`. ### public_mode @@ -610,7 +610,7 @@ Path to the default home dashboard. If this value is empty, then Grafana uses St Set to `false` to prohibit users from being able to sign up / create user accounts. Default is `false`. The admin user can still create -users from the [Grafana Admin Pages](/reference/admin). +users from the [Grafana Admin Pages]({{< relref "../manage-users/server-admin/server-admin-manage-users.md" >}}). ### allow_org_create @@ -1113,7 +1113,7 @@ Sets a global limit on number of alert rules that can be created. Default is -1 ## [unified_alerting] -For more information about the Grafana 8 alerts, refer to [Unified Alerting]({{< relref "../alerting/unified-alerting/_index.md" >}}). +For more information about the Grafana alerts, refer to [Unified Alerting]({{< relref "../alerting/unified-alerting/_index.md" >}}). ### enabled @@ -1197,15 +1197,15 @@ The interval string is a possibly signed sequence of decimal numbers, followed b ## [alerting] -For more information about the Alerting feature in Grafana, refer to [Alerts overview]({{< relref "../alerting/_index.md" >}}). +For more information about the legacy dashboard alerting feature in Grafana, refer to [Alerts overview]({{< relref "../alerting/_index.md" >}}). ### enabled -Set to `false` to [enable Grafana 8 alerting]({{}}) and to disable legacy alerting engine. Default is `true`. +Set to `false` to [enable Grafana alerting]({{}}) and to disable legacy alerting engine. to disable Grafana alerting, set to `true`. ### execute_alerts -Turns off alert rule execution, but Alerting is still visible in the Grafana UI. +Turns off alert rule execution, but alerting is still visible in the Grafana UI. ### error_or_timeout @@ -1582,7 +1582,7 @@ We do _not_ recommend using this option. For more information, refer to [Plugin ### plugin_admin_enabled -Available to Grafana administrators only, the plugin admin app is set to `true` by default. Set it to `false` to disable the app. +Available to Grafana administrators only, enables installing / uninstalling / updating plugins directly from the Grafana UI. Set to `true` by default. Setting it to `false` will hide the install / uninstall / update controls. For more information, refer to [Plugin catalog]({{< relref "../plugins/catalog.md" >}}). @@ -1711,13 +1711,19 @@ Mode `reusable` will have one browser instance and will create a new incognito p ### rendering_clustering_mode -When rendering_mode = clustered you can instruct how many browsers or incognito pages can execute concurrently. Default is `browser` and will cluster using browser instances. +When rendering_mode = clustered, you can instruct how many browsers or incognito pages can execute concurrently. Default is `browser` and will cluster using browser instances. Mode `context` will cluster using incognito pages. ### rendering_clustering_max_concurrency -When rendering_mode = clustered you can define the maximum number of browser instances/incognito pages that can execute concurrently. +When rendering_mode = clustered, you can define the maximum number of browser instances/incognito pages that can execute concurrently. Default is `5`. + +### rendering_clustering_timeout + +> **Note**: Available in grafana-image-renderer v3.3.0 and later versions. + +When rendering_mode = clustered, you can specify the duration a rendering request can take before it will time out. Default is `30` seconds. ### rendering_viewport_max_width diff --git a/docs/sources/administration/database-encryption-enterprise.md b/docs/sources/administration/database-encryption-enterprise.md new file mode 100644 index 0000000000000..d0974ab581cfe --- /dev/null +++ b/docs/sources/administration/database-encryption-enterprise.md @@ -0,0 +1,22 @@ ++++ +title = "Database encryption (Enterprise)" +description = "Grafana Enterprise database encryption" +keywords = ["grafana", "enterprise", "database", "encryption", "documentation"] +aliases = [""] +weight = 440 ++++ + +# Grafana Enterprise database encryption + +If you are using Grafana Enterprise, you can change Grafana’s cryptographic mode of operation from AES-CFB to AES-GCM, and integrate with a key management system (KMS) provider. + +## Changing your encryption mode to AES-GCM + +Grafana encrypts secrets using Advanced Encryption Standard in Cipher +FeedBack mode (AES-CFB). You might prefer to use AES in Galois/Counter +Mode (AES-GCM) instead, to meet your company’s security requirements or +in order to maintain consistency with other services. + +To change your encryption mode, update the `algorithm` value in the +`[security.encryption]` section of your Grafana configuration file. +For details, refer to Enterprise configuration. diff --git a/docs/sources/administration/database-encryption.md b/docs/sources/administration/database-encryption.md new file mode 100644 index 0000000000000..ebdfaefd4a6e9 --- /dev/null +++ b/docs/sources/administration/database-encryption.md @@ -0,0 +1,15 @@ ++++ +title = "Database encryption" +description = "Grafana database encryption" +keywords = ["grafana", "database", "encryption", "documentation"] +aliases = [""] +weight = 450 ++++ + +# Grafana database encryption + +Grafana’s database contains secrets, which are used to query data sources, send alert notifications and perform other functions within Grafana. + +Grafana encrypts these secrets before they are written to the database, by using a symmetric-key encryption algorithm called Advanced Encryption Standard (AES), and using a [secret key]({{< relref "../administration/configuration/#secret_key" >}}) that you can change when you configure a new Grafana instance. + +You can also use envelope encryption, which complements a KMS integration by adding a layer of indirection to the encryption process. diff --git a/docs/sources/alerting/_index.md b/docs/sources/alerting/_index.md index 9e2e2d6890845..ac3a4398e9898 100644 --- a/docs/sources/alerting/_index.md +++ b/docs/sources/alerting/_index.md @@ -7,21 +7,28 @@ weight = 110 Alerts allow you to learn about problems in your systems moments after they occur. Robust and actionable alerts help you identify and resolve issues quickly, minimizing disruption to your services. -Grafana 8.0 has new and improved alerting that centralizes alerting information in a single, searchable view. It allows you to: +Grafana 8.0 introduced new and improved alerting that centralizes alerting information in a single, searchable view. It allows you to: - Create and manage Grafana alerts - Create and manage Cortex and Loki managed alerts - View alerting information from Prometheus and Alertmanager compatible data sources -Grafana 8 alerting has four key components: +Grafana alerting is enabled by default for new OSS installations. For older installations, it is still an [opt-in]({{< relref "./unified-alerting/opt-in.md" >}}) feature. + +| Release | Cloud | Enterprise | OSS | +| ----------- | ------------- | ---------- | -------------------------------- | +| Grafana 8.2 | On by default | Opt-in | Opt-in | +| Grafana 8.3 | On by default | Opt-in | On by default for new installs\* | + +> **Note:** New installs include existing installs which do not have any alerts configured. + +Grafana alerting has four key components: - Alerting rule - Evaluation criteria that determine whether an alert will fire. It consists of one or more queries and expressions, a condition, the frequency of evaluation, and optionally, the duration over which the condition is met. - Contact point - Channel for sending notifications when the conditions of an alerting rule are met. - Notification policy - Set of matching and grouping criteria used to determine where and how frequently to send notifications. - Silences - Date and matching criteria used to silence notifications. -To learn more, see [What's New with Grafana 8 alerting]({{< relref "../alerting/unified-alerting/difference-old-new.md" >}}). - -For information on how to create and manage Grafana 8 alerts and notifications, refer to [Overview of Grafana 8.0 alerts]({{< relref "../alerting/unified-alerting/_index.md" >}}) and [Create and manage Grafana 8 alerting rules]({{< relref "./unified-alerting/alerting-rules/_index.md" >}}). +To learn more, see [What's New with Grafana alerting]({{< relref "../alerting/unified-alerting/difference-old-new.md" >}}). -> **Note:** Grafana 8 alerting is an [opt-in]({{< relref "./unified-alerting/opt-in.md" >}}) feature. Out of the box, Grafana still supports old [legacy dashboard alerts]({{< relref "./old-alerting/_index.md" >}}). We encourage you to create issues in the Grafana GitHub repository for bugs found while testing Grafana 8 alerts. +For information on how to create and manage Grafana alerts and notifications, refer to [Overview of Grafana alerts]({{< relref "../alerting/unified-alerting/_index.md" >}}) and [Create and manage Grafana alerting rules]({{< relref "./unified-alerting/alerting-rules/_index.md" >}}). diff --git a/docs/sources/alerting/old-alerting/_index.md b/docs/sources/alerting/old-alerting/_index.md index 262edccbf27fd..32695e28573b4 100644 --- a/docs/sources/alerting/old-alerting/_index.md +++ b/docs/sources/alerting/old-alerting/_index.md @@ -5,9 +5,11 @@ weight = 114 # Legacy Grafana alerts -Out of the box, Grafana still supports legacy dashboard alerts. If you are using version 8.0 or later, you can [opt-in]({{< relref "../unified-alerting/opt-in.md" >}}) to use Grafana 8 alerts. See [What's New with Grafana 8 alerting]({{< relref "../unified-alerting/difference-old-new.md" >}}) for more information. +Grafana alerting is enabled by default for new OSS installations. For older installations, it is still an [opt-in]({{< relref "../unified-alerting/opt-in.md" >}}) feature. -Legacy alerts have two main components: +> **Note**: Legacy dashboard alerts is deprecated and will be removed in a future release. We encourage you to migrate to [Grafana alerting]({{< relref "../unified-alerting/_index.md" >}}) for all existing installations. + +Legacy dashboard alerts have two main components: - Alert rule - When the alert is triggered. Alert rules are defined by one or more conditions that are regularly evaluated by Grafana. - Notification channel - How the alert is delivered. When the conditions of an alert rule are met, the Grafana notifies the channels configured for that alert. diff --git a/docs/sources/alerting/unified-alerting/_index.md b/docs/sources/alerting/unified-alerting/_index.md index 8c993bd409271..55deca2585a76 100644 --- a/docs/sources/alerting/unified-alerting/_index.md +++ b/docs/sources/alerting/unified-alerting/_index.md @@ -1,25 +1,26 @@ +++ -title = "Grafana 8 Alerts" +title = "Grafana alerts" aliases = ["/docs/grafana/latest/alerting/metrics/"] weight = 113 +++ -# Overview of Grafana 8 alerting +# Overview of Grafana alerting -Grafana 8.0 has new and improved alerting that centralizes alerting information in a single, searchable view. It is an [opt-in]({{< relref "./opt-in.md" >}}) feature. We encourage you to create issues in the Grafana GitHub repository for bugs found while testing Grafana 8 alerting. See also, [What's New with Grafana 8 alerting]({{< relref "./difference-old-new.md" >}}). +Grafana 8.0 has new and improved alerting that centralizes alerting information in a single, searchable view. It is enabled by default for all new OSS instances, and is an [opt-in]({{< relref "./opt-in.md" >}}) feature for older installations that still use legacy dashboard alerting. We encourage you to create issues in the Grafana GitHub repository for bugs found while testing Grafana alerting. See also, [What's New with Grafana alerting]({{< relref "./difference-old-new.md" >}}). -When Grafana 8 alerting is enabled, you can: +When Grafana alerting is enabled, you can: -- [Create a Grafana managed alerting rules]({{< relref "alerting-rules/create-grafana-managed-rule.md" >}}) -- [Create a Cortex or Loki managed alerting rules]({{< relref "alerting-rules/create-cortex-loki-managed-rule.md" >}}) +- [Create Grafana managed alerting rules]({{< relref "alerting-rules/create-grafana-managed-rule.md" >}}) +- [Create Cortex or Loki managed alerting rules]({{< relref "alerting-rules/create-cortex-loki-managed-rule.md" >}}) - [View existing alerting rules and manage their current state]({{< relref "alerting-rules/rule-list.md" >}}) - [View the state and health of alerting rules]({{< relref "./fundamentals/state-and-health.md" >}}) - [Add or edit an alert contact point]({{< relref "./contact-points.md" >}}) - [Add or edit notification policies]({{< relref "./notification-policies.md" >}}) - [Add or edit silences]({{< relref "./silences.md" >}}) -Before you begin using Grafana 8 alerting, we recommend that you familiarize yourself with some [basic concepts]({{< relref "./fundamentals/_index.md" >}}) of Grafana 8 alerting. +Before you begin using Grafana alerting, we recommend that you familiarize yourself with some [basic concepts]({{< relref "./fundamentals/_index.md" >}}) of Grafana alerting. ## Limitations -- The Grafana 8 alerting system can retrieve rules from all available Prometheus, Loki, and Alertmanager data sources. It might not be able to fetch rules from other supported data sources. +- The Grafana alerting system can retrieve rules from all available Prometheus, Loki, and Alertmanager data sources. It might not be able to fetch rules from other supported data sources. +- We aim to support the latest two minor versions of both Prometheus and Alertmanager. We cannot guarantee that older versions will work. As an example, if the current Prometheus version is `2.31.1`, we support >= `2.29.0`. diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/_index.md b/docs/sources/alerting/unified-alerting/alerting-rules/_index.md index 2de5ab16144dc..5b45b1700f309 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/_index.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/_index.md @@ -4,17 +4,17 @@ aliases = ["/docs/grafana/latest/alerting/rules/"] weight = 130 +++ -# Create and manage Grafana 8 alerting rules +# Create and manage Grafana alerting rules An alerting rule is a set of evaluation criteria that determines whether an alert will fire. The rule consists of one or more queries and expressions, a condition, the frequency of evaluation, and optionally, the duration over which the condition is met. While queries and expressions select the data set to evaluate, a condition sets the threshold that an alert must meet or exceed to create an alert. An interval specifies how frequently an alerting rule is evaluated. Duration, when configured, indicates how long a condition must be met. The rules can also define alerting behavior in the absence of data. -In Grafana 8 alerting, you can: +You can: - [Create Cortex or Loki managed alert rule]({{< relref "./create-cortex-loki-managed-rule.md" >}}) - [Create Cortex or Loki managed recording rule]({{< relref "./create-cortex-loki-managed-recording-rule.md" >}}) - [Edit Cortex or Loki rule groups and namespaces]({{< relref "./edit-cortex-loki-namespace-group.md" >}}) - [Create Grafana managed alert rule]({{< relref "./create-grafana-managed-rule.md" >}}) -- [State and hfundamentalsealth of alerting rules]({{< relref "../fundamentals/state-and-health.md" >}}) +- [State and health of alerting rules]({{< relref "../fundamentals/state-and-health.md" >}}) - [Manage alerting rules]({{< relref "./rule-list.md" >}}) diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md b/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md index eaf26a965d538..c51c957447caf 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/alert-annotation-label.md @@ -7,54 +7,32 @@ weight = 401 # Annotations and labels for alerting rules -Annotations and labels help customize alert messages so that you can quickly identify the service or application that needs attention. +Annotations and labels are key value pairs associated with alerts originating from the alerting rule, datasource response, and as a result of alerting rule evaluation. They can be used in alert notifications directly or in [templates]({{< relref "../message-templating/" >}}) and [template functions]({{< relref "../message-templating/template-functions" >}}) to create notification contact dynamically. ## Annotations -Annotations are key-value pairs that provide additional meta-information about an alert. For example: a description, a summary, and runbook URL. These are displayed in rule and alert details in the UI and can be used in contact type message templates. Annotations can also be templated, for example `Instance {{ $labels.instance }} down` will have the evaluated `instance` label value added for every alert this rule produces. +Annotations are key-value pairs that provide additional meta-information about an alert. For example: a description, a summary, and runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates. ## Labels -Labels are key-value pairs that categorize or identify an alert. Labels are used to match alerts in silences or match and groups alerts in notification policies. Labels are also shown in rule or alert details in the UI and can be used in contact type message templates. For example, you can add a `severity` label, then configure a separate notification policy for each severity. You can also add, for example, a `team` label and configure notification policies specific to the team or silence all alerts for a particular team. Labels can also be templated like annotations, for example, `{{ $labels.namespace }}/{{ $labels.job }}` will produce a new rule label that will have the evaluated `namespace` and `job` label value added for every alert this rule produces. The rule labels take precedence over the labels produced by the query/condition. +Labels are key-value pairs that contain information about, and are used to uniquely identify an alert. The label set for an alert is generated and added to throughout the alerting evaluation and notification process. + +### How are labels used? + +- The complete set of labels for an alert is what uniquely identifies an alert within Grafana Alerts. +- The Alertmanager uses labels to match alerts for [silences]({{< relref "../silences/" >}}) and [alert groups]({{< relref "../alert-groups/" >}}) in [notification policies]({{< relref "../notification-policies/" >}}). +- The alerting UI displays labels for every alert instance generated by the evaluation of that rule. +- Contact points can access labels to dynamically generate notifications that contain information specific to the alert that is resulting in a notification. +- Labels can be added to an [alerting rule]({{< relref "../alerting-rules/" >}}). These manually configured labels are able to use template functions and reference other labels. Labels added to an alerting rule here take precedence in the event of a collision between labels. {{< figure src="/static/img/docs/alerting/unified/rule-edit-details-8-0.png" max-width="550px" caption="Alert details" >}} -#### Template variables +#### Variables available to alerting rule labels and annotations The following template variables are available when expanding annotations and labels. -| Name | Description | -| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| $labels | The labels from the query or condition. For example, `{{ $labels.instance }}` and `{{ $labels.job }}`. This is unavailable when the rule uses a classic condition. | -| $values | The values of all reduce and math expressions that were evaluated for this alert rule. For example, `{{ $values.A }}`, `{{ $values.A.Labels }}` and `{{ $values.A.Value }}` where `A` is the `refID` of the expression. This is unavailable when the rule uses a [classic condition]({{< relref "./create-grafana-managed-rule/#single-and-multi-dimensional-rule" >}}) | -| $value | The value string of the alert instance. For example, `[ var='A' labels={instance=foo} value=10 ]`. | - -#### Template functions - -The following template functions are available when expanding annotations and labels. - -| Name | Argument | Return | Description | -| ------------------ | ------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| humanize | number or string | string | Converts a number to a more readable format, using metric prefixes. | -| humanize1024 | number or string | string | Like humanize, but uses 1024 as the base rather than 1000. | -| humanizeDuration | number or string | string | Converts a duration in seconds to a more readable format. | -| humanizePercentage | number or string | string | Converts a ratio value to a fraction of 100. | -| humanizeTimestamp | number or string | string | Converts a Unix timestamp in seconds to a more readable format. | -| title | string | string | strings.Title, capitalises first character of each word. | -| toUpper | string | string | strings.ToUpper, converts all characters to upper case. | -| toLower | string | string | strings.ToLower, converts all characters to lower case. | -| match | pattern, text | boolean | regexp.MatchString Tests for a unanchored regexp match. | -| reReplaceAll | pattern, replacement, text | string | Regexp.ReplaceAllString Regexp substitution, unanchored. | -| graphLink | string - JSON Object with `"expr"` and `"datasource"` fields | string | Returns the path to graphical view in [Explore](https://grafana.com/docs/grafana/latest/explore/) for the given expression and data source. | -| tableLink | string- JSON Object with `"expr"` and `"datasource"` fields | string | Returns the path to tabular view in [Explore](https://grafana.com/docs/grafana/latest/explore/) for the given expression and data source. | -| args | []interface{} | map[string]interface{} | Converts a list of objects to a map with keys, for example, arg0, arg1. Use this function to pass multiple arguments to templates. | -| externalURL | nothing | string | Returns a string representing the external URL. | -| pathPrefix | nothing | string | Returns the path of the external URL. | -| tmpl | string, []interface{} | nothing | Not supported | -| safeHtml | string | string | Not supported | -| query | query string | []sample | Not supported | -| first | []sample | sample | Not supported | -| label | label, sample | string | Not supported | -| strvalue | []sample | string | Not supported | -| value | sample | float64 | Not supported | -| sortByLabel | label, []samples | []sample | Not supported | +| Name | Description | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| $labels | The labels from the query or condition. For example, `{{ $labels.instance }}` and `{{ $labels.job }}`. This is unavailable when the rule uses a [classic condition]({{< relref "./create-grafana-managed-rule/#single-and-multi-dimensional-rule" >}}). | +| $values | The values of all reduce and math expressions that were evaluated for this alert rule. For example, `{{ $values.A }}`, `{{ $values.A.Labels }}` and `{{ $values.A.Value }}` where `A` is the `refID` of the expression. This is unavailable when the rule uses a classic condition | +| $value | The value string of the alert instance. For example, `[ var='A' labels={instance=foo} value=10 ]`. | diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md index e62f079115baf..10c8e04a3ceae 100644 --- a/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md @@ -48,7 +48,7 @@ Use the classic condition expression to create a rule that triggers a single ale To generate a separate alert for each series, create a multi-dimensional rule. Use `Math`, `Reduce`, or `Resample` expressions to create a multi-dimensional rule. For example: -- Add a `Reduce` expression for each query to aggregate values in the selected time range into a single value. (Not needed for [rules using numeric data]({{< relref "../fundamentals/grafana-managed-numeric-rule.md" >}})). +- Add a `Reduce` expression for each query to aggregate values in the selected time range into a single value. (Not needed for [rules using numeric data]({{< relref "../fundamentals/evaluate-grafana-alerts.md#alerting-on-numeric-data-1" >}})). - Add a `Math` expression with the condition for the rule. Not needed in case a query or a reduce expression already returns 0 if rule should not fire, or a positive number if it should fire. Some examples: `$B > 70` if it should fire in case value of B query/expression is more than 70. `$B < $C * 100` in case it should fire if value of B is less than value of C multiplied by 100. If queries being compared have multiple series in their results, series from different queries are matched if they have the same labels or one is a subset of the other. ![Query section multi dimensional](/static/img/docs/alerting/unified/rule-edit-multi-8-0.png 'Query section multi dimensional screenshot') @@ -63,13 +63,14 @@ For more information, see [expressions documentation]({{< relref "../../../panel Configure alerting behavior in the absence of data using information in the following tables. -| No Data Option | Description | -| -------------- | ----------------------------------------------------------------------------------------------------- | -| No Data | Set alert state to `NoData` and rule state to `Normal` (notifications are not sent on NoData states). | -| Alerting | Set alert rule state to `Alerting`. | -| Ok | Set alert rule state to `Normal`. | - -| Error or timeout option | Description | -| ----------------------- | ---------------------------------- | -| Alerting | Set alert rule state to `Alerting` | -| OK | Set alert rule state to `Normal` | +| No Data Option | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| No Data | Create a new alert `DatasourceNoData` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | +| Alerting | Set alert rule state to `Alerting`. | +| Ok | Set alert rule state to `Normal`. | + +| Error or timeout option | Description | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Alerting | Set alert rule state to `Alerting` | +| OK | Set alert rule state to `Normal` | +| Error | Create a new alert `DatasourceError` with the name and UID of the alert rule, and UID of the datasource that returned no data as labels. | diff --git a/docs/sources/alerting/unified-alerting/contact-points.md b/docs/sources/alerting/unified-alerting/contact-points.md index 3c6e6109dc7c2..853cf3e2b40f6 100644 --- a/docs/sources/alerting/unified-alerting/contact-points.md +++ b/docs/sources/alerting/unified-alerting/contact-points.md @@ -7,7 +7,7 @@ weight = 430 # Contact points -Use contact points to define how your contacts are notified when an alert fires. A contact point can have one or more contact point types, for example, email, slack, webhook, and so on. When an alert fires, a notification is sent to all contact point types listed for a contact point. Optionally, use [mesasge templates]({{< relref "./message-templating/_index.md" >}}) to customize notification messages for the contact point types. +Use contact points to define how your contacts are notified when an alert fires. A contact point can have one or more contact point types, for example, email, slack, webhook, and so on. When an alert fires, a notification is sent to all contact point types listed for a contact point. Optionally, use [message templates]({{< relref "./message-templating/_index.md" >}}) to customize notification messages for the contact point types. You can configure Grafana managed contact points as well as contact points for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). @@ -64,27 +64,27 @@ To edit global configuration options for an external Alertmanager, like SMTP ser ## List of notifiers supported by Grafana -| Name | Type | -| --------------------------------------------- | ------------------------- | -| [DingDing](#dingdingdingtalk) | `dingding` | -| [Discord](#discord) | `discord` | -| [Email](#email) | `email` | -| [Google Hangouts Chat](#google-hangouts-chat) | `googlechat` | -| [Kafka](#kafka) | `kafka` | -| Line | `line` | -| Microsoft Teams | `teams` | -| [Opsgenie](#opsgenie) | `opsgenie` | -| [Pagerduty](#pagerduty) | `pagerduty` | -| Prometheus Alertmanager | `prometheus-alertmanager` | -| [Pushover](#pushover) | `pushover` | -| Sensu | `sensu` | -| [Sensu Go](#sensu-go) | `sensugo` | -| [Slack](#slack) | `slack` | -| Telegram | `telegram` | -| Threema | `threema` | -| VictorOps | `victorops` | -| [Webhook](#webhook) | `webhook` | -| [Zenduty](#zenduty) | `webhook` | +| Name | Type | Grafana Alertmanager | Other Alertmanagers | +| --------------------------------------------- | ------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- | +| [DingDing](#dingdingdingtalk) | `dingding` | Supported | N/A | +| [Discord](#discord) | `discord` | Supported | N/A | +| [Email](#email) | `email` | Supported | Supported | +| [Google Hangouts Chat](#google-hangouts-chat) | `googlechat` | Supported | N/A | +| [Kafka](#kafka) | `kafka` | Supported | N/A | +| Line | `line` | Supported | N/A | +| Microsoft Teams | `teams` | Supported | N/A | +| [Opsgenie](#opsgenie) | `opsgenie` | Supported | Supported | +| [Pagerduty](#pagerduty) | `pagerduty` | Supported | Supported | +| Prometheus Alertmanager | `prometheus-alertmanager` | Supported | N/A | +| [Pushover](#pushover) | `pushover` | Supported | Supported | +| Sensu | `sensu` | Supported | N/A | +| [Sensu Go](#sensu-go) | `sensugo` | Supported | N/A | +| [Slack](#slack) | `slack` | Supported | Supported | +| Telegram | `telegram` | Supported | N/A | +| Threema | `threema` | Supported | N/A | +| VictorOps | `victorops` | Supported | Supported | +| [Webhook](#webhook) | `webhook` | Supported | Supported ([different format](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config)) | +| [Zenduty](#zenduty) | `webhook` | Supported | N/A | ### Webhook diff --git a/docs/sources/alerting/unified-alerting/difference-old-new.md b/docs/sources/alerting/unified-alerting/difference-old-new.md index 832ec387ffc14..a5875ab4a1cbb 100644 --- a/docs/sources/alerting/unified-alerting/difference-old-new.md +++ b/docs/sources/alerting/unified-alerting/difference-old-new.md @@ -1,13 +1,13 @@ +++ -title = "What's new in Grafana 8 alerting" -description = "What's New with Grafana 8 Alerts" +title = "What's new in Grafana alerting" +description = "What's New with Grafana alerts" keywords = ["grafana", "alerting", "guide"] weight = 114 +++ -# What's new in Grafana 8 alerting +# What's new in Grafana alerting -Grafana 8.0 alerting has several enhancements over legacy dashboard alerting. +Grafana alerting has several enhancements over legacy dashboard alerting. ## Multi-dimensional alerting @@ -15,12 +15,27 @@ You can now create alerts that give you system-wide visibility with a single ale ## Create alerts outside of Dashboards -Unlike legacy dashboard alerts, Grafana 8 alerts allow you to create queries and expressions that combine data from multiple sources in unique ways. You can still link dashboards and panels to alerting rules using their ID and quickly troubleshoot the system under observation. +Unlike legacy dashboard alerts, Grafana alerts allow you to create queries and expressions that combine data from multiple sources in unique ways. You can still link dashboards and panels to alerting rules using their ID and quickly troubleshoot the system under observation. + +Since unified alerts are no longer directly tied to panel queries, they do not include images or query values in the notification email. You can use customized notification templates to view query values. ## Create Loki and Cortex alerting rules -In Grafana 8 alerting, you can manage Loki and Cortex alerting rules using the same UI and API as your Grafana managed alerts. +In Grafana alerting, you can manage Loki and Cortex alerting rules using the same UI and API as your Grafana managed alerts. ## View and search for alerts from Prometheus compatible data sources Alerts for Prometheus compatible data sources are now listed under the Grafana alerts section. You can search for labels across multiple data sources to quickly find relevant alerts. + +## Special alerts for alert state NoData and Error + +Grafana alerting introduced a new concept of the alert states. When evaluation of an alerting rule produces state NoData or Error, Grafana alerting will generate special alerts that will have the following labels: + +- `alertname` with value DatasourceNoData or DatasourceError depending on the state. +- `rulename` name of the alert rule the special alert belongs to. +- `datasource_uid` will have the UID of the data source that caused the state. +- all labels and annotations of the original alert rule + +You can handle these alerts the same way as regular alerts by adding a silence, route to a contact point, and so on. + +> **Note:** If the rule uses many data sources and one or many returns no data, the special alert will be created for each data source that caused the alert state. diff --git a/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md b/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md index 6b46408c689fa..9b6fd56dc7f6c 100644 --- a/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md +++ b/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md @@ -10,9 +10,9 @@ The Alertmanager helps both group and manage alert rules, adding a layer of orch Grafana includes built-in support for Prometheus Alertmanager. By default, notifications for Grafana managed alerts are handled by the embedded Alertmanager that is part of core Grafana. You can configure the Alertmanager's contact points, notification policies, silences, and templates from the alerting UI by selecting the `Grafana` option from the Alertmanager drop-down. -> **Note:** Before v8.2, the configuration of the embedded Alertmanager was shared across organizations. If you are on an older Grafana version, we recommend that you use Grafana 8 Alerts only if you have one organization. Otherwise, your contact points are visible to all organizations. +> **Note:** Before v8.2, the configuration of the embedded Alertmanager was shared across organizations. If you are on an older Grafana version, we recommend that you use Grafana alerts only if you have one organization. Otherwise, your contact points are visible to all organizations. -Grafana 8 alerting added support for external Alertmanager configuration. When you add an [Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}), the Alertmanager drop-down shows a list of available external Alertmanager data sources. Select a data source to create and manage alerting for standalone Cortex or Loki data sources. +Grafana alerting added support for external Alertmanager configuration. When you add an [Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}), the Alertmanager drop-down shows a list of available external Alertmanager data sources. Select a data source to create and manage alerting for standalone Cortex or Loki data sources. {{< figure max-width="40%" src="/static/img/docs/alerting/unified/contact-points-select-am-8-0.gif" max-width="250px" caption="Select Alertmanager" >}} diff --git a/docs/sources/alerting/unified-alerting/message-templating/_index.md b/docs/sources/alerting/unified-alerting/message-templating/_index.md index 19fc8a0a319d8..909b1939a19b2 100644 --- a/docs/sources/alerting/unified-alerting/message-templating/_index.md +++ b/docs/sources/alerting/unified-alerting/message-templating/_index.md @@ -14,7 +14,7 @@ Since most of the contact point fields can be templated, you can create reusable ### Using templates -The following example shows the use of default templates to render an alert message in slack. The message title contains a count of firing or resolved alerts and the message body has a list of alerts with status. +The following example shows how to use default templates to render an alert message in Slack. The message title contains a count of alerts that are firing or were resolved. The message body lists the alerts and their status. @@ -52,12 +52,41 @@ The `define` tag in the Content section assigns the template name. This tag is o Use caution when deleting a template since Grafana does not prevent you from deleting templates that are in use. +### Nested templates + +You can embed templates within other templates. + +For example, you can define a template fragment using the `define` keyword: + +``` +{{ define "mytemplate" }} + {{ len .Alerts.Firing }} firing. {{ len .Alerts.Resolved }} resolved. +{{ end }} +``` + +You can then embed custom templates within this fragment using the `template` keyword. For example: + +``` +Alert summary: +{{ template "mytemplate" . }} +``` + +You can use any of the following built-in template options to embed custom templates. + +| Name | Notes | +| ----------------------- | ------------------------------------------------------------- | +| `default.title` | Displays high-level status information. | +| `default.message` | Provides a formatted summary of firing and resolved alerts. | +| `teams.default.message` | Similar to `default.messsage`, formatted for Microsoft Teams. | + ### Custom template examples +Here are a few examples of how to use custom templates. + Template to render a single alert: ``` -{{ define "alert" }} +{{ define "myalert" }} [{{.Status}}] {{ .Labels.alertname }} Labels: @@ -84,14 +113,20 @@ Template to render a single alert: Template to render entire notification message: ``` -{{ define "message" }} +{{ define "mymessage" }} {{ if gt (len .Alerts.Firing) 0 }} {{ len .Alerts.Firing }} firing: - {{ range .Alerts.Firing }} {{ template "alert" .}} {{ end }} + {{ range .Alerts.Firing }} {{ template "myalert" .}} {{ end }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} {{ len .Alerts.Resolved }} resolved: - {{ range .Alerts.Resolved }} {{ template "alert" .}} {{ end }} + {{ range .Alerts.Resolved }} {{ template "myalert" .}} {{ end }} {{ end }} {{ end }} ``` + +### HTML in Message Templates + +HTML in alerting message templates is escaped. We do not support rendering of HTML in the resulting notification. + +Some notifiers support alternative methods of changing the look and feel of the resulting notification. For example, Grafana installs the base template for alerting emails to `/public/emails/ng_alert_notification.html`. You can edit this file to change the appearance of all alerting emails. diff --git a/docs/sources/alerting/unified-alerting/message-templating/template-data.md b/docs/sources/alerting/unified-alerting/message-templating/template-data.md index 22684b221c7ef..22423f5909049 100644 --- a/docs/sources/alerting/unified-alerting/message-templating/template-data.md +++ b/docs/sources/alerting/unified-alerting/message-templating/template-data.md @@ -59,17 +59,3 @@ In addition to direct access of data (labels and annotations) stored as KeyValue | Remove | []string | KeyValue | Returns a copy of the Key/Value map without the given keys. | | Names | | []string | List of label names | | Values | | []string | List of label values | - -## Functions - -Some functions to transform values are also available, along with [default functions provided by Go templating](https://golang.org/pkg/text/template/#hdr-Functions). - -| Name | Arguments | Returns | -| ------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | -| title | string | Capitalizes first character of each word. | -| toUpper | string | Converts all characters to upper case. | -| match | pattern, string | Match a string using RegExp. | -| reReplaceAll | pattern, replacement, string | RegExp substitution, unanchored. | -| join | string, []string | Concatenates the elements of the second argument to create a single string. First argument is the separator. | -| safeHtml | string | Marks string as HTML, not requiring auto-escaping. | -| stringSlice | ...string | Returns passed strings as slice of strings. | diff --git a/docs/sources/alerting/unified-alerting/message-templating/template-functions.md b/docs/sources/alerting/unified-alerting/message-templating/template-functions.md new file mode 100644 index 0000000000000..b018418b5fd8a --- /dev/null +++ b/docs/sources/alerting/unified-alerting/message-templating/template-functions.md @@ -0,0 +1,138 @@ ++++ +title = "Template functions" +keywords = ["grafana", "alerting", "guide", "contact point", "templating"] ++++ + +# Template Functions + +Template functions allow you to process labels and annotations to generate dynamic notifications. + +| Name | Argument type | Return type | Description | +| ----------------------------------------- | ------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| [humanize](#humanize) | number or string | string | Converts a number to a more readable format, using metric prefixes. | +| [humanize1024](#humanize1024) | number or string | string | Like humanize, but uses 1024 as the base rather than 1000. | +| [humanizeDuration](#humanizeduration) | number or string | string | Converts a duration in seconds to a more readable format. | +| [humanizePercentage](#humanizepercentage) | number or string | string | Converts a ratio value to a fraction of 100. | +| [humanizeTimestamp](#humanizetimestamp) | number or string | string | Converts a Unix timestamp in seconds to a more readable format. | +| [title](#title) | string | string | strings.Title, capitalises first character of each word. | +| [toUpper](#toupper) | string | string | strings.ToUpper, converts all characters to upper case. | +| [toLower](#tolower) | string | string | strings.ToLower, converts all characters to lower case. | +| [match](#match) | pattern, text | boolean | regexp.MatchString Tests for a unanchored regexp match. | +| [reReplaceAll](#rereplaceall) | pattern, replacement, text | string | Regexp.ReplaceAllString Regexp substitution, unanchored. | +| [graphLink](#graphlink) | string - JSON Object with `"expr"` and `"datasource"` fields | string | Returns the path to graphical view in [Explore](https://grafana.com/docs/grafana/latest/explore/) for the given expression and data source. | +| [tableLink](#tablelink) | string- JSON Object with `"expr"` and `"datasource"` fields | string | Returns the path to tabular view in [Explore](https://grafana.com/docs/grafana/latest/explore/) for the given expression and data source. | +| [args](#args) | []interface{} | map[string]interface{} | Converts a list of objects to a map with keys, for example, arg0, arg1. Use this function to pass multiple arguments to templates. | +| [externalURL](#externalurl) | nothing | string | Returns a string representing the external URL. | +| [pathPrefix](#pathprefix) | nothing | string | Returns the path of the external URL. | + +## Examples + +### humanize + +**Template string** `{ humanize $value }` + +**Input** `1234567.0` + +**Expected** `1.235M` + +### humanize1024 + +**TemplateString** `{ humanize1024 $value } ` + +**Input** `1048576.0` + +**Expected** `1Mi` + +### humanizeDuration + +**TemplateString** `{ humanizeDuration $value }` + +**Input** `899.99` + +**Expected** `14m 59s` + +### humanizePercentage + +**TemplateString** `{ humanizePercentage $value }` + +**Input** `0.1234567` + +**Expected** `12.35%` + +### humanizeTimestamp + +**TemplateString** `{ $value | humanizeTimestamp }` + +**Input** `1435065584.128` + +**Expected** `2015-06-23 13:19:44.128 +0000 UTC` + +### title + +**TemplateString** `{ $value | title }` + +**Input** `aa bb CC` + +**Expected** `Aa Bb Cc` + +### toUpper + +**TemplateString** `{ $value | toUpper }` + +**Input** `aa bb CC` + +**Expected** `AA BB CC` + +### toLower + +**TemplateString** `{ $value | toLower }` + +**Input** `aA bB CC` + +**Expected** `aa bb cc` + +### match + +**TemplateString** `{ match "a+" $labels.instance }` + +**Input** `aa` + +**Expected** `true` + +### reReplaceAll + +**TemplateString** `{{ reReplaceAll "localhost:(.*)" "my.domain:$1" $labels.instance }}` + +**Input** `localhost:3000` + +**Expected** `my.domain:3000` + +### graphLink + +**TemplateString** `{{ graphLink "{\"expr\": \"up\", \"datasource\": \"gdev-prometheus\"}" }}` + +**Expected** `/explore?left=["now-1h","now","gdev-prometheus",{"datasource":"gdev-prometheus","expr":"up","instant":false,"range":true}]` + +### tableLink + +**TemplateString** `{{ tableLink "{\"expr\": \"up\", \"datasource\": \"gdev-prometheus\"}" }}` + +**Expected** `/explore?left=["now-1h","now","gdev-prometheus",{"datasource":"gdev-prometheus","expr":"up","instant":true,"range":false}]` + +### args + +**TemplateString** `{{define "x"}}{{.arg0}} {{.arg1}}{{end}}{{template "x" (args 1 "2")}}` + +**Expected** `1 2` + +### externalURL + +**TemplateString** `{ externalURL }` + +**Expected** `http://localhost/path/prefix` + +### pathPrefix + +**TemplateString** `{ pathPrefix }` + +**Expected** `/path/prefix` diff --git a/docs/sources/alerting/unified-alerting/notification-policies.md b/docs/sources/alerting/unified-alerting/notification-policies.md index 4a92369d7a767..ba1cbf0b6389d 100644 --- a/docs/sources/alerting/unified-alerting/notification-policies.md +++ b/docs/sources/alerting/unified-alerting/notification-policies.md @@ -11,6 +11,20 @@ Notification policies determine how alerts are routed to contact points. Policie You can configure Grafana managed notification policies as well as notification policies for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). +## Grouping + +{{< figure max-width="40%" src="/static/img/docs/alerting/unified/notification-policies-grouping.png" max-width="650px" caption="Notification policies grouping" >}} + +Grouping is a new and key concept of Grafana alerting that categorizes alert notifications of similar nature into a single funnel. This allows you to properly route alert notifications during larger outages when many parts of a system fail at once causing a high number of alerts to fire simultaneously. + +For example, suppose you have 100 services connected to a database in different environments. These services are differentiated by the label `env=environmentname`. An alert rule is in place to monitor whether your services can reach the database named `alertname=DatabaseUnreachable`. + +When a network partition occurs, half of your services can no longer reach the database. As a result, 50 different alerts (assuming half of your services) are fired. For this situation, you want to receive a single-page notification (as opposed to 50) with a list of the environments that are affected. + +You can configure grouping to be `group_by: [alertname]` (take note that the `env` label is omitted). With this configuration in place, Grafana sends a single compact notification that has all the affected environments for this alert rule. + +> **Note:** Grafana also has a special label named `...` that you can use to group all alerts by all labels (effectively disabling grouping), therefore each alert will go into its own group. It is different from the default of `group_by: null` where **all** alerts go into a single group. + ## Edit root notification policy > **Note:** Before Grafana v8.2, the configuration of the embedded Alertmanager was shared across organisations. Users of Grafana 8.0 and 8.1 are advised to use the new Grafana 8 Alerts only if they have one organisation. Otherwise, silences for the Grafana managed alerts will be visible by all organizations. diff --git a/docs/sources/alerting/unified-alerting/opt-in.md b/docs/sources/alerting/unified-alerting/opt-in.md index 98b4e080b9488..1618d8cdeb614 100644 --- a/docs/sources/alerting/unified-alerting/opt-in.md +++ b/docs/sources/alerting/unified-alerting/opt-in.md @@ -1,56 +1,60 @@ +++ -title = "Opt-in to Grafana 8 alerting" -description = "Enable Grafana 8 Alerts" +title = "Opt-in to Grafana alerting" +description = "Enable Grafana alerts" weight = 115 +++ -# Opt-in to Grafana 8 alerting +# Opt-in to Grafana alerting -This topic describes how to opt-in to Grafana 8 alerting and the rules and restrictions that govern the migration of existing dashboard alerts to the new alerting system. You can [disable Grafana 8 alerts]({{< relref "./opt-in.md#disable-grafana-8-alerts" >}}) and use the legacy dashboard alerting if needed. +Grafana alerting is enabled by default for new Cloud and OSS installations. + +**Note:** If you are an existing Grafana Cloud user and want to explore unified alerting, contact Grafana Support. They will enable unified alerting for your Cloud stack. + +For older OSS installations that use legacy dashboard alerts, unified alerting is still an opt-in feature. This topic describes how to opt-in to Grafana alerting if you have an existing Grafana installation and the rules and restrictions that govern the migration of existing dashboard alerts to the new alerting system. You can [disable Grafana alerts]({{< relref "./opt-in.md#disable-grafana-alerts" >}}) and use the legacy dashboard alerting if needed. Before you begin, we recommend that you backup Grafana's database. If you are using PostgreSQL as the backend database, then the minimum required version is 9.5. -## Enable Grafana 8 alerting +## Enable Grafana alerting -To enable Grafana 8 alerts: +To enable Grafana alerts: 1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [unified alerts]({{< relref "../../administration/configuration.md#unified_alerting" >}}) section. 1. Set the `enabled` property to `true`. 1. Next, for [legacy dashboard alerting]({{< relref "../../administration/configuration.md#alerting" >}}), set the `enabled` flag to `false`. 1. Restart Grafana for the configuration changes to take effect. -> **Note:** The `ngalert` toggle previously used to enable or disable Grafana 8 alerting is no longer available. +> **Note:** The `ngalert` toggle previously used to enable or disable Grafana alerting is no longer available. Before v8.2, notification logs and silences were stored on a disk. If you did not use persistent disks, you would have lost any configured silences and logs on a restart, resulting in unwanted or duplicate notifications. We no longer require the use of a persistent disk. Instead, the notification logs and silences are stored regularly (every 15 minutes). If you used the file-based approach, Grafana reads the existing file and persists it eventually. -## Migrating legacy alerts to Grafana 8 alerting system +## Migrating legacy alerts to Grafana alerting system -When Grafana 8 alerting is enabled, existing legacy dashboard alerts migrate in a format compatible with the Grafana 8 alerting. In the Alerting page of your Grafana instance, you can view the migrated alerts alongside new alerts. +When Grafana alerting is enabled or Grafana is upgraded to version 8.3, existing legacy dashboard alerts migrate in a format compatible with the Grafana alerting. In the Alerting page of your Grafana instance, you can view the migrated alerts alongside new alerts. -Read and write access to legacy dashboard alerts and Grafana 8 alerts are governed by the permissions of the folders storing them. During migration, legacy dashboard alert permissions are matched to the new rules permissions as follows: +Read and write access to legacy dashboard alerts and Grafana alerts are governed by the permissions of the folders storing them. During migration, legacy dashboard alert permissions are matched to the new rules permissions as follows: - If alert's dashboard has permissions, it will create a folder named like `Migrated {"dashboardUid": "UID", "panelId": 1, "alertId": 1}` to match permissions of the dashboard (including the inherited permissions from the folder). - If there are no dashboard permissions and the dashboard is under a folder, then the rule is linked to this folder and inherits its permissions. - If there are no dashboard permissions and the dashboard is under the General folder, then the rule is linked to the `General Alerting` folder, and the rule inherits the default permissions. -> **Note:** Since there is no `Keep Last State` option for [`No Data` and `Error handling`]({{< relref "./alerting-rules/create-grafana-managed-rule/#no-data--error-handling" >}}) in Grafana 8 alerting, this option becomes `Alerting` during the legacy rules migration. +> **Note:** Since there is no `Keep Last State` option for [`No Data`]({{< relref "./alerting-rules/create-grafana-managed-rule/#no-data--error-handling" >}}) in Grafana alerting, this option becomes `NoData` during the legacy rules migration. Option "Keep Last State" for [`Error handling`]({{< relref "./alerting-rules/create-grafana-managed-rule/#no-data--error-handling" >}}) is migrated to a new option `Error`. To match the behavior of the `Keep Last State`, in both cases, during the migration Grafana automatically creates a [silence]({{< relref "./silences.md" >}}) for each alert rule with a duration of 1 year. Notification channels are migrated to an Alertmanager configuration with the appropriate routes and receivers. Default notification channels are added as contact points to the default route. Notification channels not associated with any Dashboard alert go to the `autogen-unlinked-channel-recv` route. -Since `Hipchat` and `Sensu` notification channels are no longer supported, legacy alerts associated with these channels are not automatically migrated to Grafana 8 alerting. Assign the legacy alerts to a supported notification channel so that you continue to receive notifications for those alerts. +Since `Hipchat` and `Sensu` notification channels are no longer supported, legacy alerts associated with these channels are not automatically migrated to Grafana alerting. Assign the legacy alerts to a supported notification channel so that you continue to receive notifications for those alerts. Silences (expiring after one year) are created for all paused dashboard alerts. ### Limitation -Grafana 8 alerting system can retrieve rules from all available Prometheus, Loki, and Alertmanager data sources. It might not be able to fetch alerting rules from all other supported data sources at this time. +Grafana alerting system can retrieve rules from all available Prometheus, Loki, and Alertmanager data sources. It might not be able to fetch alerting rules from all other supported data sources at this time. -## Disable Grafana 8 alerts +## Disable Grafana alerts -To disable Grafana 8 alerts and enable legacy dashboard alerts: +To disable Grafana alerts and enable legacy dashboard alerts: -1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [Grafana 8 alerting]({{< relref "../../administration/configuration.md#unified_alerting" >}}) section. +1. In your custom configuration file ($WORKING_DIR/conf/custom.ini), go to the [Grafana alerting]({{< relref "../../administration/configuration.md#unified_alerting" >}}) section. 1. Set the `enabled` property to `false`. 1. For [legacy dashboard alerting]({{< relref "../../administration/configuration.md#alerting" >}}), set the `enabled` flag to `true`. 1. Restart Grafana for the configuration changes to take effect. -> **Note:** If you choose to migrate from Grafana 8 alerting to legacy dashboard alerting, you will lose any new alerts created in the Grafana 8 alerting system. +> **Note:** Switching from one flavor of alerting to another can result in data loss. This is applicable to the fresh installation as well as upgraded setups. diff --git a/docs/sources/dashboards/time-range-controls.md b/docs/sources/dashboards/time-range-controls.md index 023283ed566a6..3bc88cf25e08e 100644 --- a/docs/sources/dashboards/time-range-controls.md +++ b/docs/sources/dashboards/time-range-controls.md @@ -112,6 +112,8 @@ For more advanced time settings, click the **Dashboard settings** (gear) icon at In [Query options]({{< relref "../panels/queries.md#query-options" >}}), you can 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. This allows you to show metrics from different time periods or days at the same time. +> **Note:** Panel time overrides have no effect when the time range for the dashboard is absolute. + ## Control the time range using a URL Time range of a dashboard can be controlled by providing following query parameters in the dashboard URL: diff --git a/docs/sources/datasources/aws-cloudwatch/_index.md b/docs/sources/datasources/aws-cloudwatch/_index.md index 2cb8a0aae70cd..7311be8cec5a4 100644 --- a/docs/sources/datasources/aws-cloudwatch/_index.md +++ b/docs/sources/datasources/aws-cloudwatch/_index.md @@ -8,48 +8,23 @@ weight = 200 # AWS CloudWatch data source -Grafana ships with built-in support for CloudWatch. Add it as a data source, then you are ready to build dashboards or use Explore with CloudWatch metrics and CloudWatch Logs. +Grafana ships with built-in support for CloudWatch. This topic describes queries, templates, variables, and other configuration specific to the CloudWatch data source. For instructions on how to add a data source to Grafana, refer to [Add a data source]({{< relref "../add-a-data-source.md" >}}). Only users with the organization admin role can add data sources. -This topic describes queries, templates, variables, and other configuration specific to the CloudWatch data source. For instructions on how to add a data source to Grafana, refer to [Add a data source]({{< relref "../add-a-data-source.md" >}}). Only users with the organization admin role can add data sources. +Once you have added the Cloudwatch data source, you can build dashboards or use Explore with CloudWatch metrics and CloudWatch Logs. -> **Note:** If you are having issues setting up the data source and Grafana is returning undescriptive errors, then check the log file located in /var/log/grafana/grafana.log). +> **Note:** For troubleshooting issues when setting up the Cloudwatch data source, check the `/var/log/grafana/grafana.log` file. -## Cloudwatch settings +## Configure the CloudWatch data source To access data source settings, hover your mouse over the **Configuration** (gear) icon, then click **Data Sources**, and then click the AWS Cloudwatch data source. -| Name | Description | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `Name` | The data source name. This is how you refer to the data source in panels and queries. | -| `Default` | Default data source means that it will be pre-selected for new panels. | -| `Default Region` | Used in query editor to set region (can be changed on per query basis) | -| `Custom Metrics namespace` | Specify the CloudWatch namespace of Custom metrics | -| `Auth Provider` | Specify the provider to get credentials. | -| `Credentials` profile name | Specify the name of the profile to use (if you use `~/.aws/credentials` file), leave blank for default. | -| `Assume Role Arn` | Specify the ARN of the role to assume | -| `External ID` | If you are assuming a role in another account, that has been created with an external ID, specify the external ID here. | - -### X-Ray trace links - -Link an X-Ray data source in the "X-Ray trace link" section of the configuration page to automatically add links in your logs when the log contains `@xrayTraceId` field. - -![Trace link configuration](/static/img/docs/cloudwatch/xray-trace-link-configuration-8-2.png 'Trace link configuration') - -The data source select will contain only existing data source instances of type X-Ray so in order to use this feature you need to have existing X-Ray data source already configured, see [X-Ray docs](https://grafana.com/grafana/plugins/grafana-x-ray-datasource/) for details. - -The X-Ray link will then appear in the log details section which is accessible by clicking on the log row either in Explore or in dashboard [Logs panel]({{< relref "../../visualizations/logs-panel.md" >}}). To log the `@xrayTraceId` in your logs see the [AWS X-Ray documentation](https://docs.amazonaws.cn/en_us/xray/latest/devguide/xray-services.html). To provide the field to Grafana your log queries also have to contain the `@xrayTraceId` field, for example using query `fields @message, @xrayTraceId`. - -![Trace link in log details](/static/img/docs/cloudwatch/xray-link-log-details-8-2.png 'Trace link in log details') - -## Authentication - For authentication options and configuration details, see [AWS authentication]({{< relref "aws-authentication.md" >}}) topic. -## IAM policies +### CloudWatch specific data source configuration + +#### IAM policies -Grafana needs permissions granted via IAM to be able to read CloudWatch metrics -and EC2 tags/instances/regions. You can attach these permissions to IAM roles and -utilize Grafana's built-in support for assuming roles. +Grafana needs permissions granted via IAM to be able to read CloudWatch metrics and EC2 tags/instances/regions/alarms. You can attach these permissions to the IAM role or IAM user configured in the previous step. Here is a minimal policy example: @@ -100,310 +75,214 @@ Here is a minimal policy example: } ``` -## Using the Query Editor - -The CloudWatch data source can query data from both CloudWatch metrics and CloudWatch Logs APIs, each with its own specialized query editor. You select which API you want to query with using the query mode switch on top of the editor. - -{{< figure src="/static/img/docs/v70/cloudwatch-metrics-query-field.png" max-width="800px" class="docs-image--left" caption="CloudWatch metrics query field" >}} -{{< figure src="/static/img/docs/v70/cloudwatch-logs-query-field.png" max-width="800px" class="docs-image--right" caption="CloudWatch Logs query field" >}} +#### Namespaces of Custom Metrics -## Using the Metric Query Editor +Grafana is not able to load custom namespaces through the GetMetricData API. If you still want your custom metrics to show up in the fields in the query editor, you can specify the names of the namespaces containing the custom metrics in the _Namespaces of Custom Metrics_ field. The field accepts a multiple namespaces, separated by a comma. -To create a valid query, you need to specify the namespace, metric name and at least one statistic. If `Match Exact` is enabled, you also need to specify all the dimensions of the metric you’re querying, so that the [metric schema](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html) matches exactly. If `Match Exact` is off, you can specify any number of dimensions by which you’d like to filter. Up to 100 metrics matching your filter criteria will be returned. +#### Timeout -### Dynamic queries using dimension wildcards +Timeout specifically, for CloudWatch Logs queries. Log queries don't recognize standard Grafana query timeout as they don't keep a single request open and instead periodically poll for results. Because of limits on concurrently running queries in CloudWatch they can also take a longer time to finish. -You can monitor a dynamic list of metrics by using the asterisk (\*) wildcard for one or more dimension values. +#### X-Ray trace links -{{< figure src="/static/img/docs/v65/cloudwatch-dimension-wildcard.png" max-width="800px" class="docs-image--right" caption="CloudWatch dimension wildcard" >}} - -In this example, the query returns all metrics in the namespace `AWS/EC2` with a metric name of `CPUUtilization` and ANY value for the `InstanceId` dimension are queried. This can help you monitor metrics for AWS resources, like EC2 instances or containers. For example, when new instances are created as part of an auto scaling event, they will automatically appear in the graph without needing to track the new instance IDs. This capability is currently limited to retrieving up to 100 metrics. - -Click on `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 only metrics with exactly one dimension with name ‘InstanceId’ will be returned. - -You can untoggle `Match Exact` to include metrics that have other dimensions defined. Disabling `Match Exact` also creates a search expression even if you don’t use wildcards. We simply search for any metric that matches at least the namespace, metric name, and all defined dimensions. - -### Multi-value template variables +Link an X-Ray data source in the "X-Ray trace link" section of the configuration page to automatically add links in your logs when the log contains `@xrayTraceId` field. -When defining dimension values based on multi-valued template variables, a search expression is used 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. +![Trace link configuration](/static/img/docs/cloudwatch/xray-trace-link-configuration-8-2.png 'Trace link configuration') -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 data source select will contain only existing data source instances of type X-Ray so in order to use this feature you need to have existing X-Ray data source already configured, see [X-Ray docs](https://grafana.com/grafana/plugins/grafana-x-ray-datasource/) for details. -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. +The X-Ray link will then appear in the log details section which is accessible by clicking on the log row either in Explore or in dashboard [Logs panel]({{< relref "../../visualizations/logs-panel.md" >}}). To log the `@xrayTraceId` in your logs see the [AWS X-Ray documentation](https://docs.amazonaws.cn/en_us/xray/latest/devguide/xray-services.html). To provide the field to Grafana your log queries also have to contain the `@xrayTraceId` field, for example using query `fields @message, @xrayTraceId`. -### Metric math expressions +![Trace link in log details](/static/img/docs/cloudwatch/xray-link-log-details-8-2.png 'Trace link in log details') -You can create new time series metrics by operating on top of CloudWatch metrics using mathematical functions. Arithmetic operators, unary subtraction and other functions are supported and can be applied to CloudWatch metrics. More details on the available functions can be found on [AWS Metric Math](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html) +## CloudWatch query editor -As an example, if you want to apply arithmetic operations on a metric, you can do it by giving an id (a unique string) to the raw metric as shown below. You can then use this id and apply arithmetic operations to it in the Expression field of the new metric. +The CloudWatch data source can query data from both CloudWatch metrics and CloudWatch Logs APIs, each with its own specialized query editor. You select which API you want to query with using the query mode switch on top of the editor. -Please note that in the case you use the expression field to reference another query, like `queryA * 2`, it will not be possible to create an alert rule based on that query. +![CloudWatch API modes](/static/img/docs/cloudwatch/cloudwatch-query-editor-api-modes-8.3.0.png) -### Period +### Metrics query editor -A period is the length of time associated with a specific Amazon CloudWatch statistic. Periods are defined in numbers of seconds, and valid values for period are 1, 5, 10, 30, or any multiple of 60. +The metrics query editor allows you to build two types of queries - **Metric Search** and **Metric Query**. -If the period field is left blank or set to `auto`, then it calculates automatically based on the time range and [cloudwatch's retention policy](https://aws.amazon.com/about-aws/whats-new/2016/11/cloudwatch-extends-metrics-retention-and-new-user-interface/). The formula used is `time range in seconds / 2000`, and then it snaps to the next higher value in an array of predefined periods `[60, 300, 900, 3600, 21600, 86400]` after removing periods based on retention. By clicking `Show Query Preview` in the query editor, you can see what period Grafana used. +#### Using the Metric Search option -### Deep linking from Grafana panels to the CloudWatch console +To create a valid Metric Search query specify the namespace, metric name and at least one statistic. -{{< figure src="/static/img/docs/v65/cloudwatch-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch deep linking" >}} +If `Match Exact` is enabled, you also need to specify all the dimensions of the metric you’re querying, so that the [metric schema](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html) matches exactly. If `Match Exact` is disabled, you can specify any number of dimensions by which you’d like to filter. Up to 100 metrics matching your filter criteria will be returned. -Left clicking a time series in the panel shows a context menu with a link to `View in CloudWatch console`. Clicking that link will open a new tab that will take you to the CloudWatch console and display all the metrics for that query. If you're not currently logged in to the CloudWatch console, the link will forward you to the login page. The provided link is valid for any account but will only display the right metrics if you're logged in to the account that corresponds to the selected data source in Grafana. +##### Dynamic queries using dimension wildcards -This feature is not available for metrics that are based on metric math expressions. +You can monitor a dynamic list of metrics by using the asterisk (\*) wildcard for one or more dimension values. -## Using the Logs Query Editor +![CloudWatch dimension wildcard](/static/img/docs/cloudwatch/cloudwatch-dimension-wildcard-8.3.0.png) -To query CloudWatch Logs, select the region and up to 20 log groups which you want to query. Use the main input area to write your query in [CloudWatch Logs Query Language](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_QuerySyntax.html) +In this example, the query returns all metrics in the namespace `AWS/EC2` with a metric name of `CPUUtilization` and ANY value for the `InstanceId` dimension are queried. This can help you monitor metrics for AWS resources, like EC2 instances or containers. When new instances are created as part of an auto scaling event, they will automatically appear in the graph without you having to track the new instance IDs. This capability is currently limited to retrieving up to 100 metrics. -You can also write queries returning time series data by using the [`stats` command](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_Insights-Visualizing-Log-Data.html). When making `stats` queries in Explore, you have to make sure you are in Metrics Explore mode. +You can expand the [Query inspector](https://grafana.com/docs/grafana/latest/panels/queries/#query-inspector-button) button and click `Meta Data` 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 only metrics with exactly one dimension with the name ‘InstanceId’ will be returned. -{{< figure src="/static/img/docs/v70/explore-mode-switcher.png" max-width="500px" class="docs-image--right" caption="Explore mode switcher" >}} +![CloudWatch Meta Inspector](/static/img/docs/cloudwatch/cloudwatch-meta-inspector-8.3.0.png) -To the right of the query input field is a CloudWatch Logs Insights link that opens the CloudWatch Logs Insights console with your query. You can continue exploration there if necessary. +You can disable `Match Exact` to include metrics that have other dimensions defined. Disabling `Match Exact` also creates a search expression even if you don’t use wildcards. We simply search for any metric that matches at least the namespace, metric name, and all defined dimensions. -{{< figure src="/static/img/docs/v70/cloudwatch-logs-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch Logs deep linking" >}} +##### Multi-value template variables -### Using template variables +When defining dimension values based on multi-valued template variables, a search expression is used 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. -The CloudWatch data source supports use of template variables in queries. -For an introduction to templating and template variables, refer to the [Templating]({{< relref "../../variables/_index.md" >}}) documentation. +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. -### Deep linking from Grafana panels to the CloudWatch console +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. -{{< figure src="/static/img/docs/v70/cloudwatch-logs-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch Logs deep linking" >}} -If you'd like to view your query in the CloudWatch Logs Insights console, simply click the `CloudWatch Logs Insights` button next to the query editor. -If you're not currently logged in to the CloudWatch console, the link will forward you to the login page. The provided link is valid for any account but will only display the right metrics if you're logged in to the account that corresponds to the selected data source in Grafana. +##### Metric math expressions -### Alerting +You can create new time series metrics by operating on top of CloudWatch metrics using mathematical functions. Arithmetic operators, unary subtraction and other functions are supported and can be applied to CloudWatch metrics. More details on the available functions can be found on [AWS Metric Math](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html) -Since CloudWatch Logs queries can return numeric data, for example through the use of the `stats` command, alerts are supported. -For more information on Grafana alerts, refer to [Alerting]({{< relref "../../alerting/_index.md" >}}) documentation. +As an example, if you want to apply arithmetic operations on a metric, you can do it by giving an id (a unique string) to the raw metric as shown below. You can then use this id and apply arithmetic operations to it in the Expression field of the new metric. -## Curated dashboards +Please note that in the case you use the expression field to reference another query, like `queryA * 2`, it will not be possible to create an alert rule based on that query. -The updated CloudWatch data source ships with pre-configured dashboards for five of the most popular AWS services: +##### Deep linking from Grafana panels to the CloudWatch console -- Amazon Elastic Compute Cloud `Amazon EC2`, -- Amazon Elastic Block Store `Amazon EBS`, -- AWS Lambda `AWS Lambda`, -- Amazon CloudWatch Logs `Amazon CloudWatch Logs`, and -- Amazon Relational Database Service `Amazon RDS`. +{{< figure src="/static/img/docs/v65/cloudwatch-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch deep linking" >}} -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 saving the dashboard under a different name, because otherwise the dashboard will be overwritten when a new version of the dashboard is released. +Left clicking a time series in the panel shows a context menu with a link to `View in CloudWatch console`. Clicking that link will open a new tab that will take you to the CloudWatch console and display all the metrics for that query. If you're not currently logged in to the CloudWatch console, the link will forward you to the login page. The provided link is valid for any account but will only display the right metrics if you're logged in to the account that corresponds to the selected data source in Grafana. -{{< figure src="/static/img/docs/v65/cloudwatch-dashboard-import.png" caption="CloudWatch dashboard import" >}} +This feature is not available for metrics that are based on metric math expressions. -## Templated queries +### Using the Metric Query option -Instead of hard-coding server, application, and sensor names in your metric queries, you can use variables. The variables are listed as dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the display of data in your dashboard. +> **Note:** This query option is available in Grafana 8.3 and higher versions only. -For an introduction to templating and template variables, refer to the [Templating]({{< relref "../../variables/_index.md" >}}) documentation. +Metrics Query in the CloudWatch plugin is what is referred to as **Metric Insights** in the AWS console. It's a fast, flexible, SQL-based query engine that enables you to identify trends and patterns across millions of operational metrics in real time. It uses a dialect of SQL. The query syntax is as follows. -### Query variable +``` -The CloudWatch data source provides the following queries that you can specify in the `Query` field in the Variable edit view. They allow you to fill a variable's options list with things like `region`, `namespaces`, `metric names` and `dimension keys/values`. +SELECT FUNCTION(MetricName) +FROM Namespace | SCHEMA(...) +[ WHERE labelKey OPERATOR labelValue [AND|...]] +[ GROUP BY labelKey [, ...]] +[ ORDER BY FUNCTION() [DESC | ASC] ] +[ LIMIT number] -In place of `region` you can specify `default` to use the default region configured in the data source for the query, -e.g. `metrics(AWS/DynamoDB, default)` or `dimension_values(default, ..., ..., ...)`. +``` -Read more about the available dimensions in the [CloudWatch Metrics and Dimensions Reference](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CW_Support_For_AWS.html). +The following table provides basic explanation of the query keywords. For details about the Metrics Insights syntax, refer to the [AWS documentation](https://docs.aws.amazon.com/console/cloudwatch/metricsinsights-syntax). -| Name | Description | -| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `regions()` | Returns a list of all AWS regions | -| `namespaces()` | Returns a list of namespaces CloudWatch support. | -| `metrics(namespace, [region])` | Returns a list of metrics in the namespace. (specify region or use "default" for custom metrics) | -| `dimension_keys(namespace)` | Returns a list of dimension keys in the namespace. | -| `dimension_values(region, namespace, metric, dimension_key, [filters])` | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well. | -| `ebs_volume_ids(region, instance_id)` | Returns a list of volume ids matching the specified `region`, `instance_id`. | -| `ec2_instance_attribute(region, attribute_name, filters)` | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`. | -| `resource_arns(region, resource_type, tags)` | Returns a list of ARNs matching the specified `region`, `resource_type` and `tags`. | -| `statistics()` | Returns a list of all the standard statistics | +| Keyword | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FUNCTION` | Required. Specifies the aggregate function to use, and also specifies the name of the metric that is to be queried. Valid values are AVG, COUNT, MAX, MIN, and SUM | +| `MetricName` | Required. For example, `CPUUtilization`. | +| `FROM` | Required. Specifies the source of the metric. You can specify either the metric namespace that contains the metric that is to be queried, or a SCHEMA table function. Some namespace examples are 1`AWS/EC2`, `AWS/Lambda`. | +| `SCHEMA` | Optional. Allows you to narrow down the query results to only the metrics that is an exact match or to metrics that do noy match. | +| `WHERE` | Optional. Filters the results to only those metrics that match your specified expression. For example, `WHERE InstanceType != 'c3.4xlarge'`. | +| `GROUP BY` | Optional. Groups the query results into multiple time series. For example, `GROUP BY ServiceName`. | +| `ORDER BY` | Optional. Specifies the order of time series that are returned. Options are `ASC`, `DESC`. | +| `LIMIT` | Optional. Limits the number of time series returned. | -For details about the metrics CloudWatch provides, please refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/CW_Support_For_AWS.html). +For information about limits for the Metrics Insights, please refer to the [AWS documentation](https://docs.aws.amazon.com/console/cloudwatch/metricsinsights). -#### Examples templated queries +**Builder mode** -Example dimension queries which will return list of resources for individual AWS Services: +To create a query in Builder mode: -| Query | Service | -| ----------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| `dimension_values(us-east-1,AWS/ELB,RequestCount,LoadBalancerName)` | ELB | -| `dimension_values(us-east-1,AWS/ElastiCache,CPUUtilization,CacheClusterId)` | ElastiCache | -| `dimension_values(us-east-1,AWS/Redshift,CPUUtilization,ClusterIdentifier)` | RedShift | -| `dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)` | RDS | -| `dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)` | S3 | -| `dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})` | CloudWatch Agent | -| `resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})` | ELB | -| `resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"Component":["$service"],"Environment":["$environment"]})` | ELB | -| `resource_arns(eu-west-1,ec2:instance,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})` | EC2 | +1. Browse and select a metric namespace, metric name, filter, group, and order options using information from the table above. +1. For each of these options, choose from the list of possible options. -## ec2_instance_attribute examples +Grafana automatically constructs a SQL query based on your selections. -### JSON filters +**Code mode** -The `ec2_instance_attribute` query takes `filters` in JSON format. -You can specify [pre-defined filters of ec2:DescribeInstances](http://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html). -Note that the actual filtering takes place on Amazon's servers, not in Grafana. +To create a query in the Code mode: -Filters syntax: +1. Write your SQL query. +1. To run the query, click the **Run query** above the code editor. -```javascript -{ "filter_name1": [ "filter_value1" ], "filter_name2": [ "filter_value2" ] } -``` +The code editor has a built in autocomplete feature that gives suggestions for keywords, aggregations, namespaces, metrics, labels and label values. The suggestions are shown when hitting space, comma or dollar character. You can also use the keyboard combination CTRL+Space. -Example `ec2_instance_attribute()` query +![Code editor autocomplete](/static/img/docs/cloudwatch/cloudwatch-code-editor-autocomplete-8.3.0.gif) -```javascript -ec2_instance_attribute(us - east - 1, InstanceId, { 'tag:Environment': ['production'] }); -``` +> **Note:** Usage of template variables in the code editor might interfere the autocompletion. -### Selecting attributes - -Only 1 attribute per instance can be returned. Any flat attribute can be selected (i.e. if the attribute has a single value and isn't an object or array). Below is a list of available flat attributes: - -- `AmiLaunchIndex` -- `Architecture` -- `ClientToken` -- `EbsOptimized` -- `EnaSupport` -- `Hypervisor` -- `IamInstanceProfile` -- `ImageId` -- `InstanceId` -- `InstanceLifecycle` -- `InstanceType` -- `KernelId` -- `KeyName` -- `LaunchTime` -- `Platform` -- `PrivateDnsName` -- `PrivateIpAddress` -- `PublicDnsName` -- `PublicIpAddress` -- `RamdiskId` -- `RootDeviceName` -- `RootDeviceType` -- `SourceDestCheck` -- `SpotInstanceRequestId` -- `SriovNetSupport` -- `SubnetId` -- `VirtualizationType` -- `VpcId` - -Tags can be selected by prepending the tag name with `Tags.` - -Example `ec2_instance_attribute()` query - -```javascript -ec2_instance_attribute(us - east - 1, Tags.Name, { 'tag:Team': ['sysops'] }); -``` +### Common metric query editor fields -## Using JSON format template variables +At the bottom of the metric query editor, you'll find three fields that are common to both _Metric Search_ and _Metric Query_. -Some queries accept filters in JSON format and Grafana supports the conversion of template variables to JSON. +#### Id -If `env = 'production', 'staging'`, following query will return ARNs of EC2 instances which `Environment` tag is `production` or `staging`. +The GetMetricData API requires that all queries have a unique ID. Use this field to specify an ID of choice. The ID can include numbers, letters, and underscore, and must start with a lowercase letter. If no ID is specified, grafana will generate an ID using the following pattern `query[refId of the current query row]`, e.g `queryA` for the first query row in the panel editor. -```javascript -resource_arns(us-east-1, ec2:instance, {"Environment":${env:json}}) -``` +The ID can be used to reference queries in Metric Math expressions. -## Pricing +#### Period -The Amazon CloudWatch data source for Grafana uses the `ListMetrics` and `GetMetricData` CloudWatch API calls to list and retrieve metrics. -Pricing for CloudWatch Logs is based on the amount of data ingested, archived, and analyzed via CloudWatch Logs Insights queries. -Please see the [CloudWatch pricing page](https://aws.amazon.com/cloudwatch/pricing/) for more details. +A period is the length of time associated with a specific Amazon CloudWatch statistic. Periods are defined in numbers of seconds, and valid values for period are 1, 5, 10, 30, or any multiple of 60. -Every time you pick a dimension in the query editor Grafana will issue a ListMetrics request. -Whenever you make a change to the queries in the query editor, one new request to GetMetricData will be issued. +If the period field is left blank or set to `auto`, then it calculates automatically based on the time range and [cloudwatch's retention policy](https://aws.amazon.com/about-aws/whats-new/2016/11/cloudwatch-extends-metrics-retention-and-new-user-interface/). The formula used is `time range in seconds / 2000`, and then it snaps to the next higher value in an array of predefined periods `[60, 300, 900, 3600, 21600, 86400]` after removing periods based on retention. By clicking `Show Query Preview` in the query editor, you can see what period Grafana used. -Please note that for Grafana version 6.5 or higher, all API requests to GetMetricStatistics have been replaced with calls to GetMetricData. This change enables better support for CloudWatch metric math and enables the automatic generation of search expressions when using wildcards or disabling the `Match Exact` option. 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/). +#### Alias -## Service quotas +The alias field allows you to override the default name of the metric legend. -AWS defines quotas, or limits, for resources, actions, and items in your AWS account. Depending on the number of queries in your dashboard and the number of users accessing the dashboard, you may reach the usage limits for various CloudWatch and CloudWatch Logs resources. Note that quotas are defined per account and per region. If you're using multiple regions or have set up more than one CloudWatch data source to query against multiple accounts, you need to request a quota increase for each account and each region in which you hit the limit. +##### Alias patterns -To request a quota increase, visit the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/home?r#!/services/monitoring/quotas/L-5E141212). +| Alias Pattern | Description | Example Result | +| ---------------------- | ------------------------------------------------------------- | ---------------- | +| `{{region}}` | returns the region | `us-east-1` | +| `{{period}}` | returns the period | `3000` | +| `{{metric}}` | returns the metric | `CPUUtilization` | +| `{{label}}` | returns the label returned by the API (only in Metric Search) | `i-01343` | +| `{{namespace}}` | returns the namespace (only in Metric Search) | `AWS/EC2` | +| `{{stat}}` | returns the statistic (only in Metric Search) | `Average` | +| `{{[dimension name]}}` | returns the dimension name (only in Metric Search) | `i-01343` | -Please see the AWS documentation for [Service Quotas](https://docs.aws.amazon.com/servicequotas/latest/userguide/intro.html) and [CloudWatch limits](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html) for more information. +## Using the Logs query editor -## Configure the data source with grafana.ini +To query CloudWatch Logs: -The Grafana [configuration]({{< relref "../../administration/configuration.md#aws" >}}) file includes an `AWS` section where you can customize the data source. +1. Select the region and up to 20 log groups which you want to query. +1. Use the main input area to write your query in [CloudWatch Logs Query Language](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_QuerySyntax.html). -### allowed_auth_providers +You can also write queries returning time series data by using the [`stats` command](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_Insights-Visualizing-Log-Data.html). When making `stats` queries in Explore, you have to make sure you are in Metrics Explore mode. -Specify which authentication providers are allowed for the CloudWatch data source. The following providers are enabled by default in OSS Grafana: `default` (AWS SDK default), keys (Access and secret key), credentials (Credentials file), ec2_IAM_role (EC2 IAM role). +{{< figure src="/static/img/docs/v70/explore-mode-switcher.png" max-width="500px" class="docs-image--right" caption="Explore mode switcher" >}} -### assume_role_enabled +### Deep linking from Grafana panels to the CloudWatch console -Allows you to disable `assume role (ARN)` in the CloudWatch data source. By default, assume role (ARN) is enabled for OSS Grafana. +{{< figure src="/static/img/docs/v70/cloudwatch-logs-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch Logs deep linking" >}} +If you'd like to view your query in the CloudWatch Logs Insights console, simply click the `CloudWatch Logs Insights` button next to the query editor. +If you're not currently logged in to the CloudWatch console, the link will forward you to the login page. The provided link is valid for any account but will only display the right metrics if you're logged in to the account that corresponds to the selected data source in Grafana. -### list_metrics_page_limit +## Alerting -When a custom namespace is specified in the query editor, the [List Metrics API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_ListMetrics.html) is used to populate the _Metrics_ field and the _Dimension_ fields. The API is paginated and returns up to 500 results per page. The CloudWatch data source also limits the number of pages to 500. However, you can change this limit using the `list_metrics_page_limit` variable in the [grafana configuration file](https://grafana.com/docs/grafana/latest/administration/configuration/#aws). +Since CloudWatch Logs queries can return numeric data, for example through the use of the `stats` command, alerts are supported. +For more information on Grafana alerts, refer to [Alerting]({{< relref "../../alerting/_index.md" >}}) documentation. -## Configure the data source with provisioning +## Configure CloudWatch with grafana.ini -You can configure the CloudWatch data source by customizing configuration files in Grafana's provisioning system. To know more about provisioning and learn about available configuration options, refer to the [Provisioning Grafana]({{< relref "../../administration/provisioning/#datasources" >}}) topic. +The Grafana [configuration]({{< relref "../../administration/configuration.md#aws" >}}) file includes an `AWS` section where you can customize the data source. -Here are some provisioning examples for this data source. +| Configuration option | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowed_auth_providers` | Specifies which authentication providers are allowed for the CloudWatch data source. The following providers are enabled by default in OSS Grafana: `default` (AWS SDK default), keys (Access and secret key), credentials (Credentials file), ec2_IAM_role (EC2 IAM role). | +| `assume_role_enabled` | Allows you to disable `assume role (ARN)` in the CloudWatch data source. By default, assume role (ARN) is enabled for OSS Grafana. | +| `list_metrics_page_limit` | When a custom namespace is specified in the query editor, the [List Metrics API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_ListMetrics.html) is used to populate the _Metrics_ field and the _Dimension_ fields. The API is paginated and returns up to 500 results per page. The CloudWatch data source also limits the number of pages to 500. However, you can change this limit using the `list_metrics_page_limit` variable in the [grafana configuration file](https://grafana.com/docs/grafana/latest/administration/configuration/#aws). | -### Using AWS SDK (default) +## Pricing -```yaml -apiVersion: 1 -datasources: - - name: CloudWatch - type: cloudwatch - jsonData: - authType: default - defaultRegion: eu-west-2 -``` +The Amazon CloudWatch data source for Grafana uses the `ListMetrics` and `GetMetricData` CloudWatch API calls to list and retrieve metrics. +Pricing for CloudWatch Logs is based on the amount of data ingested, archived, and analyzed via CloudWatch Logs Insights queries. +Every time you pick a dimension in the query editor Grafana will issue a ListMetrics request. Whenever you make a change to the queries in the query editor, one new request to GetMetricData will be issued. -### Using credentials' profile name (non-default) +In Grafana version 6.5 or higher, all API requests to GetMetricStatistics have been replaced with calls to GetMetricData to provide better support for CloudWatch metric math and enables the automatic generation of search expressions when using wildcards or disabling the `Match Exact` option. While GetMetricStatistics qualified for the CloudWatch API free tier, this is not the case for GetMetricData calls. -```yaml -apiVersion: 1 +For more information, please refer to the [CloudWatch pricing page](https://aws.amazon.com/cloudwatch/pricing/). -datasources: - - name: CloudWatch - type: cloudwatch - jsonData: - authType: credentials - defaultRegion: eu-west-2 - customMetricsNamespaces: 'CWAgent,CustomNameSpace' - profile: secondary -``` +## Service quotas -### Using `accessKey` and `secretKey` +AWS defines quotas, or limits, for resources, actions, and items in your AWS account. Depending on the number of queries in your dashboard and the number of users accessing the dashboard, you may reach the usage limits for various CloudWatch and CloudWatch Logs resources. Note that quotas are defined per account and per region. If you're using multiple regions or have set up more than one CloudWatch data source to query against multiple accounts, you need to request a quota increase for each account and each region in which you hit the limit. -```yaml -apiVersion: 1 +To request a quota increase, visit the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/home?r#!/services/monitoring/quotas/L-5E141212). For more information, refer to the AWS documentation for [Service Quotas](https://docs.aws.amazon.com/servicequotas/latest/userguide/intro.html) and [CloudWatch limits](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html). -datasources: - - name: CloudWatch - type: cloudwatch - jsonData: - authType: keys - defaultRegion: eu-west-2 - secureJsonData: - accessKey: '' - secretKey: '' ``` -### Using AWS SDK Default and ARN of IAM Role to Assume - -```yaml -apiVersion: 1 -datasources: - - name: CloudWatch - type: cloudwatch - jsonData: - authType: default - assumeRoleArn: arn:aws:iam::123456789012:root - defaultRegion: eu-west-2 ``` diff --git a/docs/sources/datasources/aws-cloudwatch/aws-authentication.md b/docs/sources/datasources/aws-cloudwatch/aws-authentication.md index bddf485610b9d..3d4a2fe19c363 100644 --- a/docs/sources/datasources/aws-cloudwatch/aws-authentication.md +++ b/docs/sources/datasources/aws-cloudwatch/aws-authentication.md @@ -3,7 +3,7 @@ title = "Authentication" description = "AWS authentication" keywords = ["grafana", "aws", "authentication"] aliases = ["/docs/grafana/latest/datasources/cloudwatch"] -weight = 205 +weight = 5 +++ # AWS authentication @@ -12,6 +12,14 @@ Requests from a Grafana plugin to AWS are made on behalf of an IAM role or an IA All requests to AWS APIs are performed on the server side by the Grafana backend using the official AWS SDK. +This topic has the following sections: + +- [Authentication methods](#authentication-methods) +- [Assuming a role](#assuming-a-role) +- [Endpoint](#endpoint) +- [AWS credentials file](#aws-credentials-file) +- [EKS IAM roles for service accounts](#eks-iam-roles-for-service-accounts) + ## Authentication methods You can use one of the following authentication methods. Currently, `AWS SDK Default`, `Credentials file` and `Access and secret key` are enabled by default in open source Grafana. You can enable/disable them if necessary if you have server configuration access. For more information, refer to [allowed_auth_providers]({{< relref "../../administration/configuration.md#allowed_auth_providers" >}}) documentation. @@ -36,18 +44,7 @@ If you are assuming a role in another account that was created with an external ## Endpoint -The `Endpoint` field allows you to specify a custom endpoint URL that overrides the default generated endpoint for the CloudWatch API. Leave this field blank if you want to use the default generated endpoint. For more information on why and how to use Service endpoints, refer to the [AWS service endpoints documentation](https://docs.aws.amazon.com/general/latest/gr/rande.html). - -## EKS IAM roles for service accounts - -The Grafana process in the container runs as user 472 (called "grafana"). When Kubernetes mounts your projected credentials, they will by default only be available to the root user. To allow user 472 to access the credentials (and avoid falling back to the IAM role attached to the EC2 instance), you need to provide a [security context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for your pod. - -```yaml -securityContext: - fsGroup: 472 - runAsUser: 472 - runAsGroup: 472 -``` +The `Endpoint` field allows you to specify a custom endpoint URL that overrides the default generated endpoint for the AWS service API. Leave this field blank if you want to use the default generated endpoint. For more information on why and how to use Service endpoints, refer to the [AWS service endpoints documentation](https://docs.aws.amazon.com/general/latest/gr/rande.html). ## AWS credentials file @@ -63,3 +60,14 @@ aws_access_key_id = asdsadasdasdasd aws_secret_access_key = dasdasdsadasdasdasdsa region = us-west-2 ``` + +## EKS IAM roles for service accounts + +The Grafana process in the container runs as user 472 (called "grafana"). When Kubernetes mounts your projected credentials, they will by default only be available to the root user. To allow user 472 to access the credentials (and avoid falling back to the IAM role attached to the EC2 instance), you need to provide a [security context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for your pod. + +```yaml +securityContext: + fsGroup: 472 + runAsUser: 472 + runAsGroup: 472 +``` diff --git a/docs/sources/datasources/aws-cloudwatch/preconfig-cloudwatch-dashboards.md b/docs/sources/datasources/aws-cloudwatch/preconfig-cloudwatch-dashboards.md new file mode 100644 index 0000000000000..528b699df6780 --- /dev/null +++ b/docs/sources/datasources/aws-cloudwatch/preconfig-cloudwatch-dashboards.md @@ -0,0 +1,27 @@ ++++ +title = "Curated CloudWatch dashboards" +description = "Guide for using AWS CloudWatch in Grafana" +keywords = ["grafana", "stackdriver", "google", "guide", "cloud", "monitoring"] +aliases = ["/docs/grafana/latest/datasources/cloudwatch"] +weight = 15 ++++ + +# Curated CloudWatch dashboards + +The updated CloudWatch data source ships 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`, and +- Amazon Relational Database Service `Amazon RDS`. + +To import curatedd dashboards: + +1. On the configuration page of your CloudWatch data source, click the **Dashboards** tab. + +1. Click **Import** for the dashboard you would like to use. + +In case 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. + +{{< figure src="/static/img/docs/v65/cloudwatch-dashboard-import.png" caption="CloudWatch dashboard import" >}} diff --git a/docs/sources/datasources/aws-cloudwatch/provision-cloudwatch.md b/docs/sources/datasources/aws-cloudwatch/provision-cloudwatch.md new file mode 100644 index 0000000000000..6f0f67659ccf1 --- /dev/null +++ b/docs/sources/datasources/aws-cloudwatch/provision-cloudwatch.md @@ -0,0 +1,68 @@ ++++ +title = "Provision CloudWatch" +description = "Guide for provisioning CloudWatch" +weight = 400 +aliases = ["/docs/grafana/latest/datasources/cloudwatch"] ++++ + +# Provision CloudWatch data source + +You can configure the CloudWatch data source by customizing configuration files in Grafana's provisioning system. To know more about provisioning and learn about available configuration options, refer to the [Provisioning Grafana]({{< relref "../../administration/provisioning/#datasources" >}}) topic. + +Here are some provisioning examples for this data source. + +## Using AWS SDK (default) + +```yaml +apiVersion: 1 +datasources: + - name: CloudWatch + type: cloudwatch + jsonData: + authType: default + defaultRegion: eu-west-2 +``` + +## Using credentials' profile name (non-default) + +```yaml +apiVersion: 1 + +datasources: + - name: CloudWatch + type: cloudwatch + jsonData: + authType: credentials + defaultRegion: eu-west-2 + customMetricsNamespaces: 'CWAgent,CustomNameSpace' + profile: secondary +``` + +## Using accessKey and secretKey + +```yaml +apiVersion: 1 + +datasources: + - name: CloudWatch + type: cloudwatch + jsonData: + authType: keys + defaultRegion: eu-west-2 + secureJsonData: + accessKey: '' + secretKey: '' +``` + +## Using AWS SDK Default and ARN of IAM Role to Assume + +```yaml +apiVersion: 1 +datasources: + - name: CloudWatch + type: cloudwatch + jsonData: + authType: default + assumeRoleArn: arn:aws:iam::123456789012:root + defaultRegion: eu-west-2 +``` diff --git a/docs/sources/datasources/aws-cloudwatch/template-queries-cloudwatch.md b/docs/sources/datasources/aws-cloudwatch/template-queries-cloudwatch.md new file mode 100644 index 0000000000000..e0448a0d70b3a --- /dev/null +++ b/docs/sources/datasources/aws-cloudwatch/template-queries-cloudwatch.md @@ -0,0 +1,122 @@ ++++ +title = "Template variables in CloudWatch query" +description = "Template variables in CloudWatch queryh" +weight = 10 +aliases = ["/docs/grafana/latest/datasources/cloudwatch"] ++++ + +# Using template variables in CloudWatch queries + +Instead of hard-coding server, application, and sensor names in your metric queries, you can use variables. The variables are listed as dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the display of data in your dashboard. + +For an introduction to templating and template variables, refer to the [Templating]({{< relref "../../variables/_index.md" >}}) documentation. + +## Query variable + +The CloudWatch data source provides the following queries that you can specify in the `Query` field in the Variable edit view. They allow you to fill a variable's options list with things like `region`, `namespaces`, `metric names` and `dimension keys/values`. + +In place of `region` you can specify `default` to use the default region configured in the data source for the query, +e.g. `metrics(AWS/DynamoDB, default)` or `dimension_values(default, ..., ..., ...)`. + +Read more about the available dimensions in the [CloudWatch Metrics and Dimensions Reference](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CW_Support_For_AWS.html). + +| Name | Description | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `regions()` | Returns a list of all AWS regions | +| `namespaces()` | Returns a list of namespaces CloudWatch support. | +| `metrics(namespace, [region])` | Returns a list of metrics in the namespace. (specify region or use "default" for custom metrics) | +| `dimension_keys(namespace)` | Returns a list of dimension keys in the namespace. | +| `dimension_values(region, namespace, metric, dimension_key, [filters])` | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well. | +| `ebs_volume_ids(region, instance_id)` | Returns a list of volume ids matching the specified `region`, `instance_id`. | +| `ec2_instance_attribute(region, attribute_name, filters)` | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`. | +| `resource_arns(region, resource_type, tags)` | Returns a list of ARNs matching the specified `region`, `resource_type` and `tags`. | +| `statistics()` | Returns a list of all the standard statistics | + +For details about the metrics CloudWatch provides, please refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/CW_Support_For_AWS.html). + +## Example of templated queries + +Here is an example of the dimension queries which will return list of resources for individual AWS Services: + +| Query | Service | +| ----------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `dimension_values(us-east-1,AWS/ELB,RequestCount,LoadBalancerName)` | ELB | +| `dimension_values(us-east-1,AWS/ElastiCache,CPUUtilization,CacheClusterId)` | ElastiCache | +| `dimension_values(us-east-1,AWS/Redshift,CPUUtilization,ClusterIdentifier)` | RedShift | +| `dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)` | RDS | +| `dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)` | S3 | +| `dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})` | CloudWatch Agent | +| `resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})` | ELB | +| `resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"Component":["$service"],"Environment":["$environment"]})` | ELB | +| `resource_arns(eu-west-1,ec2:instance,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})` | EC2 | + +## Using JSON format template variables + +Some queries accept filters in JSON format and Grafana supports the conversion of template variables to JSON. + +If `env = 'production', 'staging'`, following query will return ARNs of EC2 instances which `Environment` tag is `production` or `staging`. + +```javascript +resource_arns(us-east-1, ec2:instance, {"Environment":${env:json}}) +``` + +## ec2_instance_attribute examples + +### JSON filters + +The `ec2_instance_attribute` query takes `filters` in JSON format. +You can specify [pre-defined filters of ec2:DescribeInstances](http://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html). +Note that the actual filtering takes place on Amazon's servers, not in Grafana. + +Filters syntax: + +```javascript +{ "filter_name1": [ "filter_value1" ], "filter_name2": [ "filter_value2" ] } +``` + +Example `ec2_instance_attribute()` query + +```javascript +ec2_instance_attribute(us - east - 1, InstanceId, { 'tag:Environment': ['production'] }); +``` + +### Selecting attributes + +Only 1 attribute per instance can be returned. Any flat attribute can be selected (i.e. if the attribute has a single value and isn't an object or array). Below is a list of available flat attributes: + +- `AmiLaunchIndex` +- `Architecture` +- `ClientToken` +- `EbsOptimized` +- `EnaSupport` +- `Hypervisor` +- `IamInstanceProfile` +- `ImageId` +- `InstanceId` +- `InstanceLifecycle` +- `InstanceType` +- `KernelId` +- `KeyName` +- `LaunchTime` +- `Platform` +- `PrivateDnsName` +- `PrivateIpAddress` +- `PublicDnsName` +- `PublicIpAddress` +- `RamdiskId` +- `RootDeviceName` +- `RootDeviceType` +- `SourceDestCheck` +- `SpotInstanceRequestId` +- `SriovNetSupport` +- `SubnetId` +- `VirtualizationType` +- `VpcId` + +Tags can be selected by prepending the tag name with `Tags.` + +Example `ec2_instance_attribute()` query + +```javascript +ec2_instance_attribute(us - east - 1, Tags.Name, { 'tag:Team': ['sysops'] }); +``` diff --git a/docs/sources/datasources/elasticsearch.md b/docs/sources/datasources/elasticsearch.md index 2365423e6028b..07a46386c9483 100644 --- a/docs/sources/datasources/elasticsearch.md +++ b/docs/sources/datasources/elasticsearch.md @@ -20,12 +20,12 @@ visualize logs or metrics stored in Elasticsearch. You can also annotate your gr > **Note:** If you're not seeing the `Data Sources` link in your side menu it means that your current user does not have the `Admin` role for the current organization. -| Name | Description | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `Name` | The data source name. This is how you refer to the data source in panels and queries. | -| `Default` | Default data source means that it will be pre-selected for new panels. | -| `Url` | The HTTP protocol, IP, and port of your Elasticsearch server. | -| `Access` | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. | +| Name | Description | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Name` | The data source name. This is how you refer to the data source in panels and queries. | +| `Default` | Default data source means that it will be pre-selected for new panels. | +| `Url` | The HTTP protocol, IP, and port of your Elasticsearch server. | +| `Access` | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. **Note**: Browser (direct) access is deprecated and will be removed in a future release. | Access mode controls how requests to the data source will be handled. Server should be the preferred way if nothing else stated. diff --git a/docs/sources/datasources/google-cloud-monitoring/_index.md b/docs/sources/datasources/google-cloud-monitoring/_index.md index c93ce340c776f..1983ff9732cf1 100644 --- a/docs/sources/datasources/google-cloud-monitoring/_index.md +++ b/docs/sources/datasources/google-cloud-monitoring/_index.md @@ -12,7 +12,7 @@ Grafana ships with built-in support for Google Cloud Monitoring. Add it as a dat > **Note** Before Grafana v7.1, Google Cloud Monitoring was referred to as Google Stackdriver. -## Google Cloud Monitoring settings +## Configure the Google Cloud Monitoring data source To access Google Cloud Monitoring settings, hover your mouse over the **Configuration** (gear) icon, then click **Data Sources**, and then click the Google Cloud Monitoring data source. @@ -22,15 +22,9 @@ To access Google Cloud Monitoring settings, hover your mouse over the **Configur | `Default` | Default data source means that it is pre-selected for new panels. | | `Service Account Key` | Upload or paste in the Service Account Key file for a GCP Project. For more information, refer to [Using a Google Service Account Key File](#using-a-google-service-account-key-file). | -## Authentication +For authentication options and configuration details, see the [Google authentication]({{< relref "google-authentication.md" >}}) documentation. -There are two ways to authenticate the Google Cloud Monitoring plugin - either by uploading a Google JWT file, or by automatically retrieving credentials from Google metadata server. The latter option is only available when running Grafana on GCE virtual machine. - -### Using a Google Service Account Key File - -To authenticate with the Google Cloud Monitoring API, you need to create a Google Cloud Platform (GCP) Service Account for the Project you want to show data for. A Grafana data source integrates with one GCP Project. If you want to visualize data from multiple GCP Projects then you need to create one data source per GCP Project. - -#### Enable APIs +### Google Cloud Monitoring specific data source configuration The following APIs need to be enabled first: @@ -41,39 +35,13 @@ Click on the links above and click the `Enable` button: {{< figure src="/static/img/docs/v71/cloudmonitoring_enable_api.png" max-width="450px" class="docs-image--no-shadow" caption="Enable GCP APIs" >}} -#### Create a GCP Service Account for a Project - -1. Navigate to the [APIs and Services Credentials page](https://console.cloud.google.com/apis/credentials). -1. Click on the `Create credentials` dropdown/button and choose the `Service account key` option. - - {{< figure src="/static/img/docs/v71/cloudmonitoring_create_service_account_button.png" max-width="500px" class="docs-image--no-shadow" caption="Create service account button" >}} - -1. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option: - - {{< figure src="/static/img/docs/v71/cloudmonitoring_create_service_account_key.png" max-width="500px" class="docs-image--no-shadow" caption="Create service account key" >}} - -1. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown: - - {{< figure src="/static/img/docs/v71/cloudmonitoring_service_account_choose_role.png" max-width="600px" class="docs-image--no-shadow" caption="Choose role" >}} - -1. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Google Cloud Monitoring data. -1. Upload it to Grafana on the data source Configuration page. You can either upload the file or paste in the contents of the file. - - {{< figure src="/static/img/docs/v71/cloudmonitoring_grafana_upload_key.png" max-width="550px" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}} - -1. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file! - - {{< figure src="/static/img/docs/v71/cloudmonitoring_grafana_key_uploaded.png" max-width="600px" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}} - -### Using GCE Default Service Account +#### Using GCP Service Account Key File -If Grafana is running on a Google Compute Engine (GCE) virtual machine, it is possible for Grafana to automatically retrieve default credentials from the metadata server. This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. However for this to work, there are a few preconditions that need to be met. +The GCP Service Account must have the **Monitoring Viewer** role as shown in the image below: -1. First of all, you need to create a Service Account that can be used by the GCE virtual machine. For more information, refer to [Create new service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount). -1. Make sure the GCE virtual machine instance is being run as the service account that you just created. For more information, refer to [using service account for instance](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#using). -1. Allow access to the `Cloud Monitoring API` scope. +{{< figure src="/static/img/docs/v71/cloudmonitoring_create_service_account_button.png" max-width="500px" class="docs-image--no-shadow" caption="Create service account button" >}} -For more information about creating and enabling service accounts for GCE VM instances, refer to [enable service accounts for instances](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances). +If Grafana is running on a Google Compute Engine (GCE) virtual machine, the service account in use must have access to the `Cloud Monitoring API` scope. ## Using the Query Editor diff --git a/docs/sources/datasources/google-cloud-monitoring/google-authentication.md b/docs/sources/datasources/google-cloud-monitoring/google-authentication.md new file mode 100644 index 0000000000000..18b54ff350f06 --- /dev/null +++ b/docs/sources/datasources/google-cloud-monitoring/google-authentication.md @@ -0,0 +1,39 @@ ++++ +title = "Authentication" +description = "Google authentication" +keywords = ["grafana", "google", "authentication"] +aliases = ["/docs/grafana/next/datasources/cloudmonitoring/"] +weight = 5 ++++ + +# Google authentication + +Requests from a Grafana plugin to Google are made on behalf of an IAM role or an IAM user. The IAM user or IAM role must have the associated policies to perform certain API actions. Since these policies are specific to each data source, refer to the data source documentation for details. All requests to Google APIs are performed on the server-side by the Grafana backend. + +You can authenticate a Grafana plugin to Google by uploading a Google JWT file or by automatically retrieving credentials from the Google metadata server. The latter option is only available when running Grafana on GCE virtual machine. + +## Using Google Service Account Key File + +To authenticate the Grafana plugin with the Google API, create a Google Cloud Platform (GCP) Service Account for the Project you want to show data. A Grafana data source integrates with one GCP Project. If you want to visualize data from multiple GCP Projects, then create one data source per GCP Project. + +### Create a GCP Service Account for a Project + +1. Navigate to the [APIs and Services Credentials page](https://console.cloud.google.com/apis/credentials). +1. Click on the **Create credentials** dropdown and select the **Service account** option. +1. In **Service account name**, enter a name for the account. +1. From the **Role** dropdown, choose the roles required by the specific plugin. +1. Click **Done**. +1. Use the newly created account to [create a service account key](https://cloud.google.com/iam/docs/creating-managing-service-account-keys#iam-service-account-keys-create-console). A JSON key file is created and downloaded to your computer. +1. Store this file in a secure place as it allows access to your Google data. +1. Upload the key to Grafana via the data source configuration page. + The file contents is encrypted and saved in the Grafana database. Don't forget to save the file after uploading! + +## Using GCE Default Service Account + +When Grafana is running on a Google Compute Engine (GCE) virtual machine, Grafana can automatically retrieve default credentials from the metadata server. As a result, there is no need to generate a private key file for the service account. You also do not need to upload the file to Grafana. The following preconditions must be met before Grafana can retrieve default credentials. + +- You must create a Service Account for use by the GCE virtual machine. For more information, refer to [Create new service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount). +- Verify that the GCE virtual machine instance is running as the service account that you created. For more information, refer to [setting up an instance to run as a service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#using). +- Allow access to the specified API scope. + +For more information about creating and enabling service accounts for GCE instances, refer to [enabling service accounts for instances in Google documentation](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances). diff --git a/docs/sources/datasources/influxdb/_index.md b/docs/sources/datasources/influxdb/_index.md index 83187fe2b276d..e9a0b3e477d3e 100644 --- a/docs/sources/datasources/influxdb/_index.md +++ b/docs/sources/datasources/influxdb/_index.md @@ -27,21 +27,19 @@ InfluxDB data source options differ depending on which [query language](#query-l These options apply if you are using the InfluxQL query language. If you are using Flux, refer to [Flux support in Grafana]({{< relref "influxdb-flux.md" >}}). -| Name | Description | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `Name` | The data source name. This is how you refer to the data source in panels and queries. We recommend something like `InfluxDB-InfluxQL`. | -| `Default` | Default data source means that it will be pre-selected for new panels. | -| `URL` | The HTTP protocol, IP address and port of your InfluxDB API. InfluxDB API port is by default 8086. | -| `Access` | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. | - -**Note**: Browser access is deprecated and will be removed in a future release. -`Allowed cookies`| Cookies that will be forwarded to the data source. All other cookies will be deleted. -`Database` | The ID of the bucket you want to query from, copied from the [Buckets page](https://docs.influxdata.com/influxdb/v2.0/organizations/buckets/view-buckets/) of the InfluxDB UI. -`User` | The username you use to sign into InfluxDB. -`Password` | The token you use to query the bucket above, copied from the [Tokens page](https://docs.influxdata.com/influxdb/v2.0/security/tokens/view-tokens/) of the InfluxDB UI. -`HTTP mode` | How to query the database (`GET` or `POST` HTTP verb). The `POST` verb allows heavy queries that would return an error using the `GET` verb. Default is `GET`. -`Min time interval` | (Optional) Refer to [Min time interval]({{< relref "#min-time-interval" >}}). -`Max series`| (Optional) Limits the number of series/tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have lots of small time series and not all are shown. Defaults to 1000. +| Name | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Name` | The data source name. This is how you refer to the data source in panels and queries. We recommend something like `InfluxDB-InfluxQL`. | +| `Default` | Default data source means that it will be pre-selected for new panels. | +| `URL` | The HTTP protocol, IP address and port of your InfluxDB API. InfluxDB API port is by default 8086. | +| `Access` | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. **Note**: Browser (direct) access is deprecated and will be removed in a future release. | +| `Allowed cookies` | Cookies that will be forwarded to the data source. All other cookies will be deleted. | +| `Database` | The ID of the bucket you want to query from, copied from the [Buckets page](https://docs.influxdata.com/influxdb/v2.0/organizations/buckets/view-buckets/) of the InfluxDB UI. | +| `User` | The username you use to sign into InfluxDB. | +| `Password` | The token you use to query the bucket above, copied from the [Tokens page](https://docs.influxdata.com/influxdb/v2.0/security/tokens/view-tokens/) of the InfluxDB UI. | +| `HTTP mode` | How to query the database (`GET` or `POST` HTTP verb). The `POST` verb allows heavy queries that would return an error using the `GET` verb. Default is `GET`. | +| `Min time interval` | (Optional) Refer to [Min time interval]({{< relref "#min-time-interval" >}}). | +| `Max series` | (Optional) Limits the number of series/tables that Grafana processes. Lower this number to prevent abuse, and increase it if you have lots of small time series and not all are shown. Defaults to 1000. | ### Flux diff --git a/docs/sources/datasources/tempo.md b/docs/sources/datasources/tempo.md index 3886ee6640be7..eeb5ad5d77ee8 100644 --- a/docs/sources/datasources/tempo.md +++ b/docs/sources/datasources/tempo.md @@ -36,7 +36,19 @@ This is a configuration for the [trace to logs feature]({{< relref "../explore/t - **Filter by Trace ID -** Toggle to append the trace ID to the Loki query. - **Filter by Span ID -** Toggle to append the span ID to the Loki query. -![Trace to logs settings](/static/img/docs/explore/trace-to-logs-settings-8-2.png 'Screenshot of the trace to logs settings') +{{< figure src="/static/img/docs/explore/traces-to-logs-settings-8-2.png" class="docs-image--no-shadow" caption="Screenshot of the trace to logs settings" >}} + +### Service Graph + +This is a configuration for the Service Graph feature. + +-- **Data source -** Prometheus instance where the Service Graph data is stored. + +### Search + +This is a configuration for Tempo search. + +-- **Hide search -** Optionally, hide the search query option in Explore in cases where search is not configured in the Tempo instance. ### Node Graph @@ -49,18 +61,16 @@ This is a configuration for the beta Node Graph visualization. The Node Graph is You can query and display traces from Tempo via [Explore]({{< relref "../explore/_index.md" >}}). You can search for traces if you set up the trace to logs setting in the data source configuration page. To find traces to visualize, use the [Loki query editor]({{< relref "loki.md#loki-query-editor" >}}). To get search results, you must have [derived fields]({{< relref "loki.md#derived-fields" >}}) configured, which point to this data source. -{{< figure src="/static/img/docs/tempo/query-editor-search.png" class="docs-image--no-shadow" caption="Screenshot of the Tempo query editor showing the search tab" >}} +{{< figure src="/static/img/docs/tempo/query-editor-search.png" class="docs-image--no-shadow" max-width="750px" caption="Screenshot of the Tempo query editor showing the search tab" >}} To query a particular trace, select the **TraceID** query type, and then put the ID into the Trace ID field. -{{< figure src="/static/img/docs/tempo/query-editor-traceid.png" class="docs-image--no-shadow" caption="Screenshot of the Tempo TraceID query type" >}} +{{< figure src="/static/img/docs/tempo/query-editor-traceid.png" class="docs-image--no-shadow" max-width="750px" caption="Screenshot of the Tempo TraceID query type" >}} ## Upload JSON trace file You can upload a JSON file that contains a single trace to visualize it. If the file has multiple traces then the first trace is used for visualization. -{{< figure src="/static/img/docs/explore/tempo-upload-json.png" class="docs-image--no-shadow" caption="Screenshot of the Tempo data source in explore with upload selected" >}} - Here is an example JSON: ```json @@ -105,6 +115,67 @@ Here is an example JSON: } ``` +## Service Graph + +A service graph is a visual representation of the relationships between services. Each node on the graph represents a service such as an API or database. With this graph, customers can easily detect performance issues, increases in error, fault, or throttle rates in any of their services, and dive deep into corresponding traces and root causes. + +![Node graph panel](/static/img/docs/node-graph/node-graph-8-0.png 'Node graph') + +To display the service graph: + +- [Configure the Grafana Agent](https://grafana.com/docs/tempo/next/grafana-agent/service-graphs/#quickstart) to generate service graph data +- Link a Prometheus datasource in the Tempo datasource settings. +- Navigate to [Explore]({{< relref "../explore/_index.md" >}}) +- Select the Tempo datasource +- Select the **Service Graph** query type and run the query +- Optionally, filter by service name + +You can pan and zoom the view with buttons or you mouse. For details about the visualization, refer to [Node graph panel](https://grafana.com/docs/grafana/latest/panels/visualizations/node-graph/). + +Each service in the graph is represented as a circle. Numbers on the inside shows average time per request and request per second. + +The color of each circle represents the percentage of requests in each of the following states: + +- green = success +- red = fault +- yellow = errors +- purple = throttled responses + +Click on the service to see a context menu with additional links for quick navigation to other relevant information. + ## Linking Trace ID from logs You can link to Tempo trace from logs in Loki or Elastic by configuring an internal link. See the [Derived fields]({{< relref "loki.md#derived-fields" >}}) section in the [Loki data source]({{< relref "loki.md" >}}) or [Data links]({{< relref "elasticsearch.md#data-links" >}}) section in the [Elastic data source]({{< relref "elasticsearch.md" >}}) for configuration instructions. + +## Provision the Tempo data source + +You can modify the Grafana configuration files to provision the Tempo data source. Read more about how it works and all the settings you can set for data sources on the [provisioning]({{< relref "../administration/provisioning/#datasources" >}}) topic. + +Here is an example config: + +```yaml +apiVersion: 1 + +datasources: + - name: Tempo + type: tempo + # Access mode - proxy (server in the UI) or direct (browser in the UI). + access: proxy + url: http://localhost:3200 + jsonData: + httpMethod: GET + tracesToLogs: + datasourceUid: 'loki' + tags: ['job', 'instance', 'pod', 'namespace'] + spanStartTimeShift: '1h' + spanEndTimeShift: '1h' + filterByTraceID: false + filterBySpanID: false + lokiSearch: true + serviceMap: + datasourceUid: 'prometheus' + search: + hide: false + nodeGraph: + enabled: true +``` diff --git a/docs/sources/developers/plugins/_index.md b/docs/sources/developers/plugins/_index.md index b5cbf57da685a..64a4a13f582d7 100644 --- a/docs/sources/developers/plugins/_index.md +++ b/docs/sources/developers/plugins/_index.md @@ -17,6 +17,8 @@ Open the terminal, and run the following command in your [plugin directory]({{< npx @grafana/toolkit plugin:create my-grafana-plugin ``` +> **Note:** If running NPM 7+ the `npx` commands mentioned in this article may hang. The workaround is to use `npx --legacy-peer-deps `. + If you want a more guided introduction to plugin development, check out our tutorials: - [Build a panel plugin]({{< relref "/tutorials/build-a-panel-plugin.md" >}}) @@ -76,9 +78,9 @@ Learn more about Grafana options and packages. #### Typescript -- [Grafana Data]({{< relref "../../packages_api/data/_index.md" >}}) -- [Grafana Runtime]({{< relref "../../packages_api/runtime/_index.md" >}}) -- [Grafana UI]({{< relref "../../packages_api/ui/_index.md" >}}) +- Grafana Data +- Grafana Runtime +- Grafana UI #### Go diff --git a/docs/sources/developers/plugins/add-support-for-explore-queries.md b/docs/sources/developers/plugins/add-support-for-explore-queries.md index 0a842a84960b9..be4ef28037f73 100644 --- a/docs/sources/developers/plugins/add-support-for-explore-queries.md +++ b/docs/sources/developers/plugins/add-support-for-explore-queries.md @@ -46,7 +46,7 @@ The query editor for Explore is similar to the query editor for the data source .setExploreQueryField(ExploreQueryEditor); ``` -1. Add a [QueryField]({{< relref "../../packages_api/ui/queryfield.md" >}}) to `ExploreQueryEditor`. +1. Add a `QueryField` to `ExploreQueryEditor`. ```ts import { QueryField } from '@grafana/ui'; diff --git a/docs/sources/developers/plugins/add-support-for-variables.md b/docs/sources/developers/plugins/add-support-for-variables.md index 02b8ce0576f38..ee2916dea1905 100644 --- a/docs/sources/developers/plugins/add-support-for-variables.md +++ b/docs/sources/developers/plugins/add-support-for-variables.md @@ -24,7 +24,7 @@ Grafana provides a couple of helper functions to interpolate variables in a stri ## Interpolate variables in panel plugins -For panels, the [replaceVariables]({{< relref "../../packages_api/data/panelprops.md#replacevariables-property" >}}) function is available in the [PanelProps]({{< relref "../../packages_api/data/panelprops.md" >}}). +For panels, the `replaceVariables` function is available in the PanelProps. Add `replaceVariables` to the argument list, and pass it a user-defined template string. @@ -38,7 +38,7 @@ export const SimplePanel: React.FC = ({ options, data, width, height, rep ## Interpolate variables in data source plugins -For data sources, you need to use the [getTemplateSrv]({{< relref "../../packages_api/runtime/gettemplatesrv.md" >}}), which returns an instance of [TemplateSrv]({{< relref "../../packages_api/runtime/templatesrv.md" >}}). +For data sources, you need to use the getTemplateSrv, which returns an instance of TemplateSrv. 1. Import `getTemplateSrv` from the `runtime` package. @@ -80,7 +80,7 @@ For more information on the available variable formats, refer to [Advanced varia ## Set a variable from your plugin -Not only can you read the value of a variable, you can also update the variable from your plugin. Use [LocationSrv.update()]({{< relref "../../packages_api/runtime/locationsrv.md/#update-method" >}}) to update a variable using query parameters. +Not only can you read the value of a variable, you can also update the variable from your plugin. Use LocationSrv.update() The following example shows how to update a variable called `service`. @@ -117,7 +117,7 @@ export interface MyVariableQuery { } ``` -For a data source to support query variables, you must override the [`metricFindQuery`]({{< relref "../../packages_api/data/datasourceapi.md#metricfindquery-method" >}}) in your `DataSourceApi` class. `metricFindQuery` returns an array of [`MetricFindValue`]({{< relref "../../packages_api/data/metricfindvalue.md" >}}) which has a single property, `text`: +For a data source to support query variables, you must override the `metricFindQuery` in your `DataSourceApi` class. `metricFindQuery` returns an array of `MetricFindValue` which has a single property, `text`: ```ts async metricFindQuery(query: MyVariableQuery, options?: any) { diff --git a/docs/sources/developers/plugins/build-a-logs-data-source-plugin.md b/docs/sources/developers/plugins/build-a-logs-data-source-plugin.md index 747061016559c..698820ebc54f6 100644 --- a/docs/sources/developers/plugins/build-a-logs-data-source-plugin.md +++ b/docs/sources/developers/plugins/build-a-logs-data-source-plugin.md @@ -74,7 +74,7 @@ const frame = new MutableDataFrame({ To help filter log lines, many log systems let you query logs based on metadata, or _labels_. -You can add labels to a stream of logs by setting the [labels](../../packages_api/data/field.md#labels-property) property on the [Field]({{< relref "../../packages_api/data/field.md" >}}). +You can add labels to a stream of logs by setting the labels property on the Field. **Example**: diff --git a/docs/sources/developers/plugins/legacy/snapshot-mode.md b/docs/sources/developers/plugins/legacy/snapshot-mode.md index 6f98e5d236432..b8f6c96a0586e 100644 --- a/docs/sources/developers/plugins/legacy/snapshot-mode.md +++ b/docs/sources/developers/plugins/legacy/snapshot-mode.md @@ -7,7 +7,7 @@ aliases = ["/docs/grafana/latest/plugins/developing/snapshot-mode/"] {{< figure class="float-right" src="/static/img/docs/Grafana-snapshot-example.png" caption="A dashboard using snapshot data and not live data." >}} -Grafana has this great feature where you can [save a snapshot of your dashboard]({{< relref "../../../dashboards/json-model.md" >}}). Instead of sending a screenshot of a dashboard to someone, you can send them a working, interactive Grafana dashboard with the snapshot data embedded inside it. The snapshot can be saved on your Grafana server and is available to all your co-workers. Raintank also hosts a [snapshot server](http://snapshot.raintank.io/) if you want to send the snapshot to someone who does not have access to your Grafana server. +Grafana has this great feature where you can [save a snapshot of your dashboard]({{< relref "../../../dashboards/json-model.md" >}}). Instead of sending a screenshot of a dashboard to someone, you can send them a working, interactive Grafana dashboard with the snapshot data embedded inside it. The snapshot can be saved on your Grafana server and is available to all your co-workers. Raintank also hosts a [snapshot server](https://snapshots.raintank.io) if you want to send the snapshot to someone who does not have access to your Grafana server. {{< figure class="float-right" src="/static/img/docs/animated_gifs/snapshots.gif" caption="Selecting a snapshot" >}} @@ -72,4 +72,4 @@ loadLocationDataFromFile(reload) { It is really easy to forget to add this support but it enables a great feature and can be used to demo your panel. -If there is a panel plugin that you would like to be installed on the Raintank Snapshot server then please contact us via [Slack](https://raintank.slack.com) or [GitHub](https://github.com/grafana/grafana). +If there is a panel plugin that you would like to be installed on the Raintank Snapshot server then please contact us via [Slack](https://slack.grafana.com) or [GitHub](https://github.com/grafana/grafana). diff --git a/docs/sources/developers/plugins/sign-a-plugin.md b/docs/sources/developers/plugins/sign-a-plugin.md index 5e0623f02454c..9138ae8644e87 100644 --- a/docs/sources/developers/plugins/sign-a-plugin.md +++ b/docs/sources/developers/plugins/sign-a-plugin.md @@ -41,6 +41,8 @@ Public plugins need to be reviewed by the Grafana team before you can sign them. npx @grafana/toolkit plugin:sign ``` +> **Note:** If running NPM 7+ the `npx` commands mentioned in this article may hang. The workaround is to use `npx --legacy-peer-deps `. + ## Sign a private plugin 1. In your plugin directory, sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. diff --git a/docs/sources/developers/plugins/working-with-data-frames.md b/docs/sources/developers/plugins/working-with-data-frames.md index 1c67a8c7aba1b..1cfc9eead043b 100644 --- a/docs/sources/developers/plugins/working-with-data-frames.md +++ b/docs/sources/developers/plugins/working-with-data-frames.md @@ -6,7 +6,7 @@ title = "Working with data frames" The data frame is a columnar data structure which allows efficient querying of large amounts of data. Since data frames are a central concept when developing plugins for Grafana, in this guide we'll look at some ways you can use them. -The [DataFrame]({{< relref "../../packages_api/data/dataframe.md" >}}) interface contains a `name` and an array of `fields` where each field contains the name, type, and the values for the field. +The DataFrame interface contains a `name` and an array of `fields` where each field contains the name, type, and the values for the field. > **Note:** If you're looking to migrate an existing plugin to use the data frame format, refer to [Migrate to data frames]({{< relref "migration-guide.md#migrate-to-data-frames" >}}). @@ -14,7 +14,7 @@ The [DataFrame]({{< relref "../../packages_api/data/dataframe.md" >}}) interface If you build a data source plugin, then you'll most likely want to convert a response from an external API to a data frame. Let's look at how to create a data frame. -Let's start with creating a simple data frame that represents a time series. The easiest way to create a data frame is to use the [toDataFrame]({{< relref "../../packages_api/data/todataframe.md" >}}) function. +Let's start with creating a simple data frame that represents a time series. The easiest way to create a data frame is to use the toDataFrame function. ```ts // Need to be of the same length. @@ -78,7 +78,7 @@ for (let i = 0; i < frame.length; i++) { } ``` -Alternatively, you can use the [DataFrameView]({{< relref "../../packages_api/data/dataframeview.md" >}}), which gives you an array of objects that contain a property for each field in the frame. +Alternatively, you can use the DataFrameView, which gives you an array of objects that contain a property for each field in the frame. ```ts const view = new DataFrameView(frame); @@ -113,7 +113,7 @@ return ( ); ``` -To apply field options to the name of a field, use [getFieldDisplayName]({{< relref "../../packages_api/data/getfielddisplayname.md" >}}). +To apply field options to the name of a field, use getFieldDisplayName. ```ts const valueField = frame.fields.find((field) => field.type === FieldType.number); diff --git a/docs/sources/enterprise/access-control/_index.md b/docs/sources/enterprise/access-control/_index.md index 14b959bb0d71f..7cb95a794bb05 100644 --- a/docs/sources/enterprise/access-control/_index.md +++ b/docs/sources/enterprise/access-control/_index.md @@ -21,18 +21,23 @@ Fine-grained access control considers a) _who_ has an access (`identity`), and b You can grant, change, or revoke access to _users_ (`identity`). When an authenticated user tries to access a Grafana resource, the authorization system checks the required fine-grained permissions for the resource and determines whether or not the action is allowed. Refer to [Fine-grained permissions]({{< relref "./permissions.md" >}}) for a complete list of available permissions. -To grant or revoke access to your users, create or remove built-in role assignments. For more information, refer to [Built-in role assignments]({{< relref "./roles.md#built-in-role-assignments" >}}). +Refer to [Assign roles]({{< relref "./roles.md#assign-roles" >}}) to learn about grant or revoke access to your users. ## Resources with fine-grained permissions -Fine-grained access control is currently available for [Reporting]({{< relref "../reporting.md" >}}) and [Managing Users]({{< relref "../../manage-users/_index.md" >}}). -To learn more about specific endpoints where you can use access control, refer to [Permissions]({{< relref "./permissions.md" >}}) and to the relevant API guide: +Fine-grained access control is available for the following capabilities: -- [Fine-grained access control API]({{< relref "../../http_api/access_control.md" >}}) -- [Admin API]({{< relref "../../http_api/admin.md" >}}) -- [Organization API]({{< relref "../../http_api/org.md" >}}) -- [Reporting API]({{< relref "../../http_api/reporting.md" >}}) -- [User API]({{< relref "../../http_api/user.md" >}}) +- [Use Explore mode]({{< relref "../../explore/_index.md" >}}) +- [Manage users]({{< relref "../../manage-users/_index.md" >}}) +- [Manage LDAP authentication]({{< relref "../../auth/ldap/_index.md" >}}) +- [Manage data sources]({{< relref "../../datasources/_index.md" >}}) +- [Manage data source permissions]({{< relref "../datasource_permissions.md" >}}) +- [Manage a Grafana Enterprise license]({{< relref "../license/_index.md" >}}) +- [Provision Grafana]({{< relref "../../administration/provisioning/_index.md" >}}) +- [Manage reports]({{< relref "../reporting.md" >}}) +- [View server information]({{< relref "../../administration/view-server/_index.md" >}}) + +To learn about specific endpoints where you can use fine-grained access control, refer to [Permissions]({{< relref "./permissions.md" >}}) and to the relevant [API]({{< relref "../../http_api/_index.md" >}}) documentation. ## Enable fine-grained access control diff --git a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md index 7feab2737ab29..5a1d99c7f7398 100644 --- a/docs/sources/enterprise/access-control/fine-grained-access-control-references.md +++ b/docs/sources/enterprise/access-control/fine-grained-access-control-references.md @@ -13,8 +13,8 @@ The reference information that follows complements conceptual information about | Fixed roles | Permissions | Descriptions | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fixed:roles:reader` | `roles:read`
`roles:list`
`roles.builtin:list` | Read all access control roles and built-in role assignments. | -| `fixed:roles:writer` | All permissions from `fixed:roles:reader` and
`roles:write`
`roles:delete`
`roles.builtin:add`
`roles.builtin:remove` | Create, read, update, or delete all roles and built-in role assignments. | +| `fixed:roles:reader` | `roles:read`
`roles:list`
`users.roles:list`
`users.permissions:list`
`roles.builtin:list` | Read all access control roles, roles and permissions assigned to users and built-in role assignments. | +| `fixed:roles:writer` | All permissions from `fixed:roles:reader` and
`roles:write`
`roles:delete`
`users.roles:add`
`users.roles:remove`
`roles.builtin:add`
`roles.builtin:remove` | Create, read, update, or delete all roles, assign or unassign roles to users and built-in role assignments. | | `fixed:reports:reader` | `reports:read`
`reports:send`
`reports.settings:read` | Read all reports and shared report settings. | | `fixed:reports:writer` | All permissions from `fixed:reports:reader` and
`reports.admin:write`
`reports:delete`
`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. | | `fixed:users:reader` | `users:read`
`users.quotas:list`
`users.authtoken:list`
`users.teams:read` | Read all users and their information, such as team memberships, authentication tokens, and quotas. | @@ -35,16 +35,16 @@ The reference information that follows complements conceptual information about | `fixed:licensing:reader` | `licensing:read`
`licensing.reports:read` | Read licensing information and licensing reports. | | `fixed:licensing:writer` | All permissions from `fixed:licensing:viewer` and
`licensing:update`
`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. | | `fixed:provisioning:writer` | `provisioning:reload` | Reload provisioning. | -| `fixed:orgs:reader` | `orgs:read`
`orgs.quotas:read` | Read the organization and its quotas. | -| `fixed:orgs:writer` | All permissions from `fixed:orgs:reader` and
`orgs:write`
`orgs:delete`
`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. | -| `fixed:current.org:reader` | `orgs:read`
`orgs.quotas:read` | Read the current organization, such as its ID, name, address, or quotas. | -| `fixed:current.org:writer` | All permissions from `fixed:current.orgs:reader` and
`orgs:write`
`orgs.quotas:write`
`orgs.preferences:read`
`orgs.preferences:write` | Read the current organization, its quotas, or its preferences. Update the current organization properties, or its preferences. | +| `fixed:organization:reader` | `orgs:read`
`orgs.quotas:read` | Read an organization and its quotas. | +| `fixed:organization:writer` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs.preferences:read`
`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. | +| `fixed:organization:maintainer` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs:create`
`orgs:delete`
`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. | +| | ## Default built-in role assignments -| Built-in role | Associated role | Description | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| Grafana Admin | `fixed:roles:reader`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:orgs:reader`
`fixed:orgs:writer`
`fixed:licensing:reader`
`fixed:licensing:writer` | Default [Grafana server administrator]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) assignments. | -| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:current.org:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
| Default [Grafana organization administrator]({{< relref "../../permissions/organization_roles.md" >}}) assignments. | -| Editor | `fixed:datasources:explorer` | Default [Editor]({{< relref "../../permissions/organization_roles.md" >}}) assignments. | -| Viewer | `fixed:datasources:id:reader` | Default [Viewer]({{< relref "../../permissions/organization_roles.md" >}}) assignments. | +| Built-in role | Associated role | Description | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Grafana Admin | `fixed:roles:reader`
`fixed:roles:writer`
`fixed:users:reader`
`fixed:users:writer`
`fixed:org.users:reader`
`fixed:org.users:writer`
`fixed:ldap:reader`
`fixed:ldap:writer`
`fixed:stats:reader`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:provisioning:writer`
`fixed:organization:reader`
`fixed:organization:maintainer`
`fixed:licensing:reader`
`fixed:licensing:writer` | Default [Grafana server administrator]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) assignments. | +| Admin | `fixed:reports:reader`
`fixed:reports:writer`
`fixed:datasources:reader`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:reader`
`fixed:datasources.permissions:writer`
| Default [Grafana organization administrator]({{< relref "../../permissions/organization_roles.md" >}}) assignments. | +| Editor | `fixed:datasources:explorer` | Default [Editor]({{< relref "../../permissions/organization_roles.md" >}}) assignments. | +| Viewer | `fixed:datasources:id:reader`
`fixed:organization:reader` | Default [Viewer]({{< relref "../../permissions/organization_roles.md" >}}) assignments. | diff --git a/docs/sources/enterprise/access-control/manage-role-assignments/_index.md b/docs/sources/enterprise/access-control/manage-role-assignments/_index.md new file mode 100644 index 0000000000000..25b93e3abe148 --- /dev/null +++ b/docs/sources/enterprise/access-control/manage-role-assignments/_index.md @@ -0,0 +1,15 @@ ++++ +title = "Manage role assignments" +description = "" +keywords = ["grafana", "fine-grained-access-control", "roles", "permissions", "enterprise"] +weight = 115 ++++ + +# Manage role assignments + +To grant or revoke access to your users, you can assign [Roles]({{< relref "../roles.md" >}}) to users, [Organization roles]({{< relref "../../../permissions/organization_roles.md" >}}) and [Grafana Server Admin]({{< relref "../../../permissions/_index.md#grafana-server-admin-role" >}}) role. + +The following pages provide more information on how to manage role assignments: + +- [Manage user role assignments]({{< relref "manage-user-role-assignments.md" >}}). +- [Manage role assignments to Organization roles and Grafana Server Admin role]({{< relref "manage-built-in-role-assignments.md" >}}). diff --git a/docs/sources/enterprise/access-control/manage-role-assignments/manage-built-in-role-assignments.md b/docs/sources/enterprise/access-control/manage-role-assignments/manage-built-in-role-assignments.md new file mode 100644 index 0000000000000..1409b7bcad159 --- /dev/null +++ b/docs/sources/enterprise/access-control/manage-role-assignments/manage-built-in-role-assignments.md @@ -0,0 +1,19 @@ ++++ +title = "Manage built-in role assignments" +description = "Manage built-in role assignments" +keywords = ["grafana", "fine-grained-access-control", "roles", "permissions", "fine-grained-access-control-usage", "enterprise"] +weight = 210 ++++ + +# Built-in role assignments + +To control what your users can access or not, you can assign or unassign [Custom roles]({{< ref "#custom-roles" >}}) or [Fixed roles]({{< ref "#fixed-roles" >}}) to the existing [Organization roles]({{< relref "../../../permissions/organization_roles.md" >}}) or to [Grafana Server Admin]({{< relref "../../../permissions/_index.md#grafana-server-admin-role" >}}) role. +These assignments are called built-in role assignments. + +During startup, Grafana will create default assignments for you. When you make any changes to the built-on role assignments, Grafana will take them into account and won’t overwrite during next start. + +For more information, refer to [Fine-grained access control references]({{< relref "../fine-grained-access-control-references.md#default-built-in-role-assignments" >}}). + +# Manage built-in role assignments + +You can create or remove built-in role assignments using [Fine-grained access control API]({{< relref "../../../http_api/access_control.md#create-and-remove-built-in-role-assignments" >}}) or using [Grafana Provisioning]({{< relref "../provisioning.md#manage-default-built-in-role-assignments" >}}). diff --git a/docs/sources/enterprise/access-control/manage-role-assignments/manage-user-role-assignments.md b/docs/sources/enterprise/access-control/manage-role-assignments/manage-user-role-assignments.md new file mode 100644 index 0000000000000..e537fe6d2bd80 --- /dev/null +++ b/docs/sources/enterprise/access-control/manage-role-assignments/manage-user-role-assignments.md @@ -0,0 +1,64 @@ ++++ +title = "Manage user role assignments" +description = "Manage user role assignments" +keywords = ["grafana", "fine-grained-access-control", "roles", "permissions", "fine-grained-access-control-usage", "enterprise"] +weight = 200 ++++ + +# Manage user role assignments + +There are two ways to assign roles directly to users: in the UI using the role picker, and using the API. + +## Manage users' roles within a specific Organization using the role picker + +In order to assign roles to a user within a specific Organization using the role picker, you must have a user account with one of the following: + +- The Admin built-in role. +- The Server Admin role. +- The fixed role `fixed:permissions:writer`, [assigned for the given Organization]({{< relref "../roles/#scope-of-assignments" >}}). +- A custom role with `users.roles:add` and `users.roles:remove` permissions. + +You must also have the permissions granted by the roles that you want to assign or revoke. + +Steps: + +1. Navigate to the Users Configuration page by hovering over **Configuration** (the gear icon) in the left navigation menu and selecting **Users**. +1. Click on the **Role** column in the row for the user whose role you would like to edit. +1. Deselect one or more selected roles that you would like to remove from that user. +1. Select one or more roles that you would like to assign to that user. +1. Click the **Apply** button to apply the selected roles to that user. + +![User role picker in Organization](/static/img/docs/enterprise/user_role_picker_global.png) + +The user's permissions will update immediately, and the UI will reflect their new permissions the next time they reload their browser or visit a new page. + +**Note**: The roles that you select will be assigned only within the given Organization. For example, if you grant the user the "Data source editor" role while you are in the main Organization, then that user will be able to edit data source in the main Organization but not in others. + +## Manage users' roles in multiple Organizations using the role picker + +In order to assign roles across multiple Organizations to a user using the role picker, you must have a user account with one of the following: + +- The Server Admin built-in role +- The fixed role `fixed:permissions:writer`, [assigned globally]({{< relref "../roles/#scope-of-assignments" >}}). +- A custom role with `users.roles:add` and `users.roles:remove` permissions, [assigned globally]({{< relref "../roles/#scope-of-assignments" >}}). + +You must also have the permissions granted by the roles that you want to assign or revoke within the Organization in which you're making changes. + +Steps: + +1. Navigate to the Users Admin page by hovering over **Server Admin** (the shield icon) in the left navigation menu and selecting **Users**. +1. Click on a user row to edit that user's roles. +1. Under the **Organizations** header, you will see a list of roles assigned to that user within each of their Organizations. Click on the roles in an organization to open the role picker. +1. Deselect one or more selected roles that you would like to remove from that user. +1. Select one or more roles that you would like to assign to that user. +1. Click the **Apply** button to apply the selected roles to that user. + +![User role picker in Organization](/static/img/docs/enterprise/user_role_picker_in_org.png) + +The user's permissions will update immediately, and the UI will reflect their new permissions the next time they reload their browser or visit a new page. + +**Note**: The roles that you select will be assigned only within one Organization. For example, if you grant the user the "Data source editor" role in the row for the main Organization, then that user will be able to edit data source in the main Organization but not in others. + +## Manage users' roles via API + +To manage user role assignment via API, refer to the [fine-grained access control HTTP API docs]({{< relref "../../../http_api/access_control.md#create-and-remove-user-role-assignments" >}}). diff --git a/docs/sources/enterprise/access-control/permissions.md b/docs/sources/enterprise/access-control/permissions.md index 29e220ef47a1d..560fc92836026 100644 --- a/docs/sources/enterprise/access-control/permissions.md +++ b/docs/sources/enterprise/access-control/permissions.md @@ -2,7 +2,7 @@ title = "Permissions" description = "Understand fine-grained access control permissions" keywords = ["grafana", "fine-grained access-control", "roles", "permissions", "enterprise"] -weight = 115 +weight = 110 +++ # Permissions @@ -54,6 +54,10 @@ The following list contains fine-grained access control actions. | `users:logout` | `global:users:*`
`global:users:id:*` | Sign out a user. | | `users.quotas:list` | `global:users:*`
`global:users:id:*` | List a user’s quotas. | | `users.quotas:update` | `global:users:*`
`global:users:id:*` | Update a user’s quotas. | +| `users.roles:list` | `users:*` | List roles assigned directly to a user. | +| `users.roles:add` | `permissions:delegate` | Assign a role to a user. | +| `users.roles:remove` | `permissions:delegate` | Unassign a role from a auser. | +| `users.permissions:list` | `users:*` | List permissions of a user. | | `org.users:read` | `users:*`
`users:id:*` | Get user profiles within an organization. | | `org.users:add` | `users:*` | Add a user to an organization. | | `org.users:remove` | `users:*`
`users:id:*` | Remove a user from an organization. | diff --git a/docs/sources/enterprise/access-control/provisioning.md b/docs/sources/enterprise/access-control/provisioning.md index 2c096b9cd199a..6601812e5451b 100644 --- a/docs/sources/enterprise/access-control/provisioning.md +++ b/docs/sources/enterprise/access-control/provisioning.md @@ -25,7 +25,7 @@ You can create, update, and delete custom roles, as well as create and remove bu To create or update custom roles, you can add a list of `roles` in the configuration. -Every role has a [version]({{< relref "./roles.md#custom-roles" >}}) number. For each role you update, you must remember to increment it, otherwise changes won't be accounted for. +Every role has a [version]({{< relref "./roles.md#custom-roles" >}}) number. For each role you update, you must remember to increment it, otherwise changes won't be applied. When you update a role, the existing role inside Grafana is altered to be exactly what is specified in the YAML file, including permissions. diff --git a/docs/sources/enterprise/access-control/roles.md b/docs/sources/enterprise/access-control/roles.md index f857808882008..ac6843eb6cff8 100644 --- a/docs/sources/enterprise/access-control/roles.md +++ b/docs/sources/enterprise/access-control/roles.md @@ -44,6 +44,14 @@ Role names must be unique within an organization. Roles with names prefixed by `fixed:` are fixed roles created by Grafana and cannot be created or modified by users. +### Display name + +A role's display name is intended as a human friendly identifier for the role, helping users understand the purpose of a role. The display name of the role is displayed in the role picker in the UI. + +### Group + +A role's group is used to organize roles in the role picker in the UI. + ### Role version The version of a role is a positive integer which defines the current version of the role. When updating a role, you can either omit the version field to increment the previous value by 1 or set a new version which must be strictly larger than the previous version for the update to succeed. @@ -67,20 +75,13 @@ If a Grafana Server Admin wants to delegate that privilege to other users, they Note that you won't be able to create, update or delete a custom role with permissions which you yourself do not have. For example, if the only permission you have is a `users:create`, you won't be able to create a role with other permissions. -## Built-in role assignments - -To control what your users can access or not, you can assign or unassign [Custom roles]({{< ref "#custom-roles" >}}) or [Fixed roles]({{< ref "#fixed-roles" >}}) to the existing [Organization roles]({{< relref "../../permissions/organization_roles.md" >}}) or to [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) role. -These assignments are called built-in role assignments. - -During startup, Grafana will create default assignments for you. When you make any changes to the built-on role assignments, Grafana will take them into account and won’t overwrite during next start. - -For more information, refer to [Fine-grained access control references]({{< relref "./fine-grained-access-control-references.md#default-built-in-role-assignments" >}}). +## Assign roles -## Create and remove built-in role assignments +[Custom roles]({{< ref "#custom-roles" >}}) and [Fixed roles]({{< ref "#fixed-roles" >}}) can be assigned to users, the existing [Organization roles]({{< relref "../../permissions/organization_roles.md" >}}) and to [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) role. -You can create or remove built-in role assignments using [Fine-grained access control API]({{< relref "../../http_api/access_control.md" >}}) or using [Grafana Provisioning]({{< relref "./provisioning" >}}). +Visit [Manage role assignments]({{< relref "manage-role-assignments/_index.md" >}}) page for more details. ### Scope of assignments -A built-in role assignment can be either _global_ or _organization local_. _Global_ assignments are not mapped to any specific organization and will be applied to all organizations, whereas _organization local_ assignments are only applied for that specific organization. +A role assignment can be either _global_ or _organization local_. _Global_ assignments are not mapped to any specific organization and will be applied to all organizations, whereas _organization local_ assignments are only applied for that specific organization. You can only create _organization local_ assignments for _organization local_ roles. diff --git a/docs/sources/enterprise/access-control/usage-scenarios.md b/docs/sources/enterprise/access-control/usage-scenarios.md index 7f8f9703e6518..de31c0ab73eb1 100644 --- a/docs/sources/enterprise/access-control/usage-scenarios.md +++ b/docs/sources/enterprise/access-control/usage-scenarios.md @@ -132,6 +132,10 @@ Example response: } ``` +## Manage roles granted directly to users + +To learn about granting roles to users, refer to [Manage user role assignments]({{< relref "manage-role-assignments/manage-user-role-assignments.md" >}}) page. + ## Create your first custom role You can create your custom role by either using an [HTTP API]({{< relref "../../http_api/access_control.md#create-a-new-custom-role" >}}) or by using [Grafana provisioning]({{< relref "./provisioning.md" >}}). diff --git a/docs/sources/enterprise/auditing.md b/docs/sources/enterprise/auditing.md index 1bb76e0374883..01982586f82f9 100644 --- a/docs/sources/enterprise/auditing.md +++ b/docs/sources/enterprise/auditing.md @@ -181,6 +181,14 @@ external group. | Restore old dashboard version | `{"action": "restore", "resources": [{"type": "dashboard"}]}` | | Delete dashboard | `{"action": "delete", "resources": [{"type": "dashboard"}]}` | +#### Library elements management + +| Action | Distinguishing fields | +| ---------------------- | ------------------------------------------------------------------ | +| Create library element | `{"action": "create", "resources": [{"type": "library-element"}]}` | +| Update library element | `{"action": "update", "resources": [{"type": "library-element"}]}` | +| Delete library element | `{"action": "delete", "resources": [{"type": "library-element"}]}` | + #### Data sources management | Action | Distinguishing fields | @@ -192,11 +200,37 @@ external group. | Disable permissions for datasource | `{"action": "disable-permissions", "resources": [{"type": "datasource"}]}` | | Grant datasource permission to role, team, or user | `{"action": "create", "resources": [{"type": "datasource"}, {"type": "dspermission"}]}`\* | | Remove datasource permission | `{"action": "delete", "resources": [{"type": "datasource"}, {"type": "dspermission"}]}` | +| Enable caching for datasource | `{"action": "enable-cache", "resources": [{"type": "datasource"}]}` | +| Disable caching for datasource | `{"action": "disable-cache", "resources": [{"type": "datasource"}]}` | +| Update datasource caching configuration | `{"action": "update", "resources": [{"type": "datasource"}]}` | \* `resources` may also contain a third item with `"type":` set to `"user"` or `"team"`. #### Alerts and notification channels management +| Action | Distinguishing fields | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Save alert manager configuration | `{"action": "update", "requestUri": "/api/alertmanager/RECIPIENT/config/api/v1/alerts"}` | +| Reset alert manager configuration | `{"action": "delete", "requestUri": "/api/alertmanager/RECIPIENT/config/api/v1/alerts"}` | +| Create silence | `{"action": "create", "requestUri": "/api/alertmanager/RECIPIENT/api/v2/silences"}` | +| Delete silence | `{"action": "delete", "requestUri": "/api/alertmanager/RECIPIENT/api/v2/silences/SILENCE-ID"}` | +| Create alert | `{"action": "create", "requestUri": "/api/ruler/RECIPIENT/api/v2/alerts"}` | +| Create or update rule group | `{"action": "create-update", "requestUri": "/api/ruler/RECIPIENT/api/v1/rules/NAMESPACE"}` | +| Delete rule group | `{"action": "delete", "requestUri": "/api/ruler/RECIPIENT/api/v1/rules/NAMESPACE/GROUP-NAME"}` | +| Delete namespace | `{"action": "delete", "requestUri": "/api/ruler/RECIPIENT/api/v1/rules/NAMESPACE"}` | +| Test Grafana managed receivers | `{"action": "test", "requestUri": "/api/alertmanager/RECIPIENT/config/api/v1/receivers/test"}` | +| Create or update the NGalert configuration of the user's organization | `{"action": "create-update", "requestUri": "/api/v1/ngalert/admin_config"}` | +| Delete the NGalert configuration of the user's organization | `{"action": "delete", "requestUri": "/api/v1/ngalert/admin_config"}` | + +Where the following: + +- `RECIPIENT` is `grafana` for requests handled by Grafana or the numeric data source ID for requests forwarded to a data source. +- `NAMESPACE` is the string identifier for the rules namespace. +- `GROUP-NAME` is the string identifier for the rules group. +- `SILENCE-ID` is the ID of the affected silence. + +The following legacy alerting actions are still supported: + | Action | Distinguishing fields | | --------------------------------- | --------------------------------------------------------------------- | | Test alert rule | `{"action": "test", "resources": [{"type": "panel"}]}` | @@ -235,13 +269,6 @@ external group. #### Provisioning -"resources": [ -{ -"id": 1, -"type": "api-key" -} -], - | Action | Distinguishing fields | | -------------------------------- | ------------------------------------------ | | Reload provisioned dashboards | `{"action": "provisioning-dashboards"}` | @@ -249,6 +276,13 @@ external group. | Reload provisioned plugins | `{"action": "provisioning-plugins"}` | | Reload provisioned notifications | `{"action": "provisioning-notifications"}` | +#### Plugins management + +| Action | Distinguishing fields | +| ---------------- | ------------------------- | +| Install plugin | `{"action": "install"}` | +| Uninstall plugin | `{"action": "uninstall"}` | + #### Miscellaneous | Action | Distinguishing fields | diff --git a/docs/sources/enterprise/enterprise-configuration.md b/docs/sources/enterprise/enterprise-configuration.md index 978558dae3760..2e6865e42180c 100644 --- a/docs/sources/enterprise/enterprise-configuration.md +++ b/docs/sources/enterprise/enterprise-configuration.md @@ -34,6 +34,12 @@ side to be valid for a different number of users or a new duration, your Grafana instance will be updated with the new terms automatically. Defaults to `true`. +### license_validation_type + +> **Note:** Available in Grafana Enterprise v8.3+. + +When set to `aws`, Grafana will validate its license status with Amazon Web Services (AWS) instead of with Grafana Labs. Only use this setting if you purchased an Enterprise license from AWS Marketplace. Defaults to empty, which means that by default Grafana Enterprise will validate using a license issued by Grafana Labs. For details about licenses issued by AWS, refer to [Activate a Grafana Enterprise license purchased through AWS Marketplace]({{< relref "../enterprise/license/activate-aws-marketplace-license/" >}}). + ## [white_labeling] ### app_title @@ -456,3 +462,42 @@ The default is `"grafana"`. A space-separated list of memcached servers. Example: `memcached-server-1:11211 memcached-server-2:11212 memcached-server-3:11211`. Or if there's only one server: `memcached-server:11211`. The default is `"localhost:11211"`. + +## [recorded_queries] + +### enabled + +Whether the recorded queries feature is enabled + +### min_interval + +Sets the minimum interval to enforce between query evaluations. The default value is `10s`. Query evaluation will be +adjusted if they are less than this value. Higher values can help with resource management. + +The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. +30s or 1m. + +### max_queries + +The maximum number of recorded queries that can exist. + +### default_remote_write_datasource_uid + +The UID of the datasource where the query data will be written. + +If all `default_remote_write_*` properties are set, this information will be populated at startup. If a remote write target has +already been configured, nothing will happen. + +### default_remote_write_path + +The api path where metrics will be written + +If all `default_remote_write_*` properties are set, this information will be populated at startup. If a remote write target has +already been configured, nothing will happen. + +### default_remote_write_datasource_org_id + +The org id of the datasource where the query data will be written. + +If all `default_remote_write_*` properties are set, this information will be populated at startup. If a remote write target has +already been configured, nothing will happen. diff --git a/docs/sources/enterprise/kms-integration/_index.md b/docs/sources/enterprise/kms-integration/_index.md new file mode 100644 index 0000000000000..ef557e8924d57 --- /dev/null +++ b/docs/sources/enterprise/kms-integration/_index.md @@ -0,0 +1,15 @@ ++++ +title = "KMS integration" +description = "" +keywords = ["grafana", "kms", "key management system integration"] +weight = 1200 ++++ + +# Key management systems (KMSs) + +You can choose to encrypt secrets stored in the Grafana database using a key from a KMS, which is a secure central storage location that is designed to help you to create and manage cryptographic keys and control their use across many services. When you integrate with a KMS, Grafana does not directly store your encryption key. Instead, Grafana stores KMS credentials and the identifier of the key, which Grafana uses to encrypt the database. + +Grafana integrates with the following key management systems: + +- AWS KMS +- Azure Key Vault diff --git a/docs/sources/enterprise/kms-integration/using-aws-kms-to-encrypt-database-secrets.md b/docs/sources/enterprise/kms-integration/using-aws-kms-to-encrypt-database-secrets.md new file mode 100644 index 0000000000000..0b49a16a2176a --- /dev/null +++ b/docs/sources/enterprise/kms-integration/using-aws-kms-to-encrypt-database-secrets.md @@ -0,0 +1,86 @@ ++++ +title = "AMS KMS" +description = "Using AWS KMS to encrypt database secrets" +keywords = ["grafana", "AWS KMS integration"] +weight = 3 ++++ + +# Using AWS KMS to encrypt database secrets + +You can use an encryption key from AWS Key Management Service to encrypt secrets in the Grafana database. + +**Prerequisites:** + +- An AWS account with permission to view and create KMS keys and programmatic credentials to access those keys +- Access to the Grafana [configuration]({{< relref "../../administration/configuration/#config-file-locations" >}}) file + +1. Create a symmetric API key either from the AWS Management Console or by using the AWS KMS API. +

For detailed instructions, refer to [Creating keys](https://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html). + +2. Retrieve the Key ID. +

In AWS terms, this can be a key ID, a key ARN (Amazon Resource Name), an alias name, or an alias ARN. For more information about how to retrieve a key ID from AWS, refer to [Finding the key ID and key ARN](https://docs.aws.amazon.com/kms/latest/developerguide/find-cmk-id-arn.html). + +3. Create a [programmatic credential](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys) (access key ID and secret access key), which has permission to view the key that you created. +

In AWS, you can control access to your KMS keys by using [key policies](https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html), [IAM policies](https://docs.aws.amazon.com/kms/latest/developerguide/iam-policies.html), and [grants](https://docs.aws.amazon.com/kms/latest/developerguide/grants.html). You can also create [temporary credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html), which must provide a session token along with an access key ID and a secret access key. + +4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}). +5. Add your AWS KMS details to the Grafana configuration file; depending on your operating system, it is usually named `grafana.ini`: +

a. Add a new section to the configuration file, with a name in the format of `[security.encryption.awskms.]`, where `` is any name that uniquely identifies this key among other provider keys. +

b. Fill in the section with the following values: +
+ + - `key_id`: a reference to a key stored in the KMS. This can be a key ID, a key Amazon Resource Name (ARN), an alias name, or an alias ARN. If you are using an alias, use the prefix `alias/`. To specify a KMS key in a different AWS account, use its ARN or alias. For more information about how to retrieve a key ID from AWS, refer to [Finding the key ID and key ARN](https://docs.aws.amazon.com/kms/latest/developerguide/find-cmk-id-arn.html).
+ | `key_id` option | Example value | + | --- | --- | + | Key ID | `1234abcd-12ab-34cd-56ef-1234567890ab` | + | Key ARN | `arn:aws:kms:us-east-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab` | + | Alias name | `alias/ExampleAlias` | + | Alias ARN | `arn:aws:kms:us-east-2:111122223333:alias/ExampleAlias` | + + - `access_key_id`: The AWS Access Key ID that you previously generated. + - `secret_access_key`: The AWS Secret Access Key you previously generated. + - `token`: (Optional) An AWS Session Token, which you must provide if you created temporary credentials. + - `region`: The AWS region where you created the KMS key. The region is contained in the key’s ARN. For example: `arn:aws:kms:*us-east-2*:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab` + + An example of an AWS KMS provider section in the `grafana.ini` file is as follows: + + ``` + # AWS key management service provider setup + ;[security.encryption.awskms.example-encryption-key] + # Reference to a KMS key - either key ID, key ARN, alias name, or ARN + ;key_id = 1234abcd-12ab-34cd-56ef-1234567890ab + # AWS access key ID + ;access_key_id = AKIAIOSFODNN7EXAMPLE + # AWS secret access key + ;secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + # AWS session token, optional + ;token = AQoDYXdzEJr... + # AWS region, for example eu-north-1 + ;region = eu-north-1 + ``` + +6. Update the `[security]` section of the `grafana.ini` configuration file with the new Encryption Provider key that you created: + + ``` + [security] + # previous encryption key, used for legacy alerts, decrypting existing secrets or used as default provider when external providers are not configured + secret_key = AaaaAaaa + # encryption provider key in the format . + encryption_provider = awskms.example-encryption-key + # list of configured key providers, space separated + available_encryption_providers = awskms.example-encryption-key + ``` + + **> Note:** The encryption key that is stored in the `secret_key` field is still used by Grafana’s legacy alerting system to encrypt secrets, for decrypting existing secrets, or it is used as the default provider when external providers are not configured. Do not change or remove that value when adding a new KMS provider. + +7. [Restart Grafana](https://grafana.com/docs/grafana/latest/installation/restart-grafana/). + +8. (Optional) From the command line and the root directory of Grafana, re-encrypt all of the secrets within the Grafana database with the new key using the following command: + + `grafana-cli admin secrets-migration re-encrypt` + + If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. + + **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources or alert notification channels) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. + + **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources, alert notification channels, or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. diff --git a/docs/sources/enterprise/kms-integration/using-azure-key-vault-to-encrypt-database-secrets.md b/docs/sources/enterprise/kms-integration/using-azure-key-vault-to-encrypt-database-secrets.md new file mode 100644 index 0000000000000..8747fa8139402 --- /dev/null +++ b/docs/sources/enterprise/kms-integration/using-azure-key-vault-to-encrypt-database-secrets.md @@ -0,0 +1,81 @@ ++++ +title = "Azure Key Vault" +description = "Using Azure Key Vault to encrypt database secrets" +keywords = ["grafana", "Azure key vault"] +weight = 2 ++++ + +# Using Azure Key Vault to encrypt database secrets + +You can use an encryption key from Azure Key Vault to encrypt secrets in the Grafana database. + +**Prerequisites:** + +- An Azure account with permission to view and create Key Vault keys and programmatic credentials to access those keys +- Access to the Grafana [configuration]({{< relref "../../administration/configuration/#config-file-locations" >}}) file + +1. [Create a vault](https://docs.microsoft.com/en-us/azure/key-vault/general/quick-create-portal#create-a-vault). + +2. Create a key in the **Key Vault** with the name that you want by using **RSA** as the type and `2048` as the size with encrypt and decrypt permissions. + +3. [Register an application](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app#register-an-application) and generate a client secret for it. + +4. [Assign a Key Vault access policy](https://docs.microsoft.com/en-us/azure/key-vault/general/assign-access-policy?tabs=azure-portal) for the key vault that you created: + +5. In the Key Permissions section, set encrypt and decrypt permissions, and click **Save**. + +6. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}). + +7. Add your Azure Key Vault details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`: +

a. Add a new section to the configuration file, with a name in the format of `[security.encryption.azurekv.]`, where `` is any name that uniquely identifies this key among other provider keys. +

b. Fill in the section with the following values: +
+ + - `tenant_id`: the **Directory ID** (tenant) from the application that you registered. + - `client_id`: the **Application ID** (client) from the application that you registered. + - `client_secret`: the VALUE of the secret that you generated in your app. (Don't use the Secret ID). + - `key_id`: the key name that you created in the key vault. + - `vault_uri`: the URL of your key vault. + + An example of an Azure Key Vault provider section in the `grafana.ini` file is as follows: + + ``` + # Azure Key Vault provider setup + ;[security.encryption.azurekv.example-encryption-key] + # Azure Application directory ID (tenant) + tenant_id = 1234abcd-12ab-34cd-56ef-1234567890ab + # Azure Application application ID (client). + client_id = 1356dfgh-12ab-34cd-56ef-3322114455cc + # Azure Application client secret. + client_secret = FbE4X~4Jq45ERKxx823Aheb9plBjQqHHe81Sc + # Azure Key Vault key name. + key_id = mysecretkey + # Azure Key Vault uri. + vault_uri = https://my-vault-name.vault.azure.net + ``` + +8. Update the `[security]` section of the `grafana.ini` configuration file with the new Encryption Provider key that you created: + + ``` + [security] + # previous encryption key, used for legacy alerts, decrypting existing secrets or used as default provider when external providers are not configured + secret_key = AaaaAaaa + # encryption provider key in the format . + encryption_provider = azurekv.example-encryption-key + # list of configured key providers, space separated + available_encryption_providers = azurekv.example-encryption-key + ``` + + **> Note:** The encryption key stored in the `secret_key` field is still used by Grafana’s legacy alerting system to encrypt secrets. Do not change or remove that value. + +9. [Restart Grafana](https://grafana.com/docs/grafana/latest/installation/restart-grafana/). + +10. (Optional) From the command line and the root directory of Grafana Enterprise, re-encrypt all of the secrets within the Grafana database with the new key using the following command: + + `grafana-cli admin secrets-migration re-encrypt` + + If you do not re-encrypt existing secrets, then they will remain encrypted by the previous encryption key. Users will still be able to access them. + + **> Note:** This process could take a few minutes to complete, depending on the number of secrets (such as data sources or alert notification channels) in your database. Users might experience errors while this process is running, and alert notifications might not be sent. + + **> Note:** If you are updating this encryption key during the initial setup of Grafana before any data sources, alert notification channels, or dashboards have been created, then this step is not necessary because there are no secrets in Grafana to migrate. diff --git a/docs/sources/enterprise/license/_index.md b/docs/sources/enterprise/license/_index.md index 108927e7d5c3b..f3595fbfc04fc 100644 --- a/docs/sources/enterprise/license/_index.md +++ b/docs/sources/enterprise/license/_index.md @@ -7,9 +7,10 @@ weight = 10 # Grafana Enterprise license -To run Grafana Enterprise, you need a valid license. [Contact a Grafana Labs representative](https://grafana.com/contact?about=grafana-enterprise) to obtain the license. For information on how to activate your license, refer to [Activate an Enterprise license]({{< relref "./activate-license.md" >}}). +When you become a Grafana Enterprise customer, you gain access to Grafana's premium observability features, including enterprise data source plugins, reporting, and fine-grained access control. In order to use these [enhanced features of Grafana Enterprise]({{< relref "../_index.md" >}}), you must purchase and activate a Grafana Enterprise license. -See also: +To purchase a license directly from Grafana Labs, [Contact a Grafana Labs representative](https://grafana.com/contact?about=grafana-enterprise). To activate an Enterprise license purchased from Grafana Labs, refer to [Activate an Enterprise license]({{< relref "./activate-license.md" >}}). -- [License expiration]({{< relref "./license-expiration.md" >}}) -- [License restrictions]({{< relref "./license-restrictions.md" >}}) +You can also purchase a Grafana Enterprise license through the AWS Marketplace. To learn more about activating a license purchased through AWS, refer to [Activate a Grafana Enterprise license purchased through AWS Marketplace]({{< relref "../license/activate-aws-marketplace-license" >}}). + +{{< section >}} diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/_index.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/_index.md new file mode 100644 index 0000000000000..06f7c4655c2b6 --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/_index.md @@ -0,0 +1,12 @@ ++++ +title = "Activate a Grafana Enterprise license purchased through AWS Marketplace" +description = "Activate Enterprise license purchased through AWS Marketplace" +keywords = ["grafana", "aws", "marketplace", "enterprise", "license"] +weight = 400 ++++ + +# Activate a Grafana Enterprise license purchased through AWS Marketplace + +AWS Marketplace is a convenient place for AWS customers to buy and manage a license for Grafana Enterprise versions 8.3.0 and later. + +{{< section >}} diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/about-ge-license-through-aws.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/about-ge-license-through-aws.md new file mode 100644 index 0000000000000..d1dff4cfa134b --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/about-ge-license-through-aws.md @@ -0,0 +1,24 @@ ++++ +title = "About Grafana Enterprise licenses from AWS Marketplace" +description = "About Grafana Enterprise licenses from AWS Marketplace" +keywords = ["grafana", "about", "enterprise", "overview", "aws", "marketplace"] +aliases = ["/docs/grafana/latest/enterprise/activate-aws-marketplace-license/about-ge-license-through-aws"] +weight = 100 ++++ + +# About Grafana Enterprise licenses from AWS Marketplace + +AWS Marketplace is a convenient place for AWS customers to buy and manage a license for Grafana Enterprise versions 8.3.0 and later. + +You can deploy Grafana Enterprise in the following ways: + +- Using AWS services like ECS, EKS or EC2. +- In an instance outside AWS. + +In each case, you must activate the Grafana Enterprise license purchased in AWS Marketplace to take advantage of Grafana Enterprise observability features. Grafana Enterprise licenses purchased through AWS Marketplace are subject to the same [restrictions]({{< relref "../license-restrictions.md" >}}) as Grafana Enterprise licensed purchased directly from Grafana Labs. + +> To purchase a license directly from Grafana Labs or learn more about other Grafana offerings, [Contact a Grafana Labs representative](https://grafana.com/contact?about=grafana-enterprise). + +## Before you begin + +Become an AWS customer. Only AWS customers have access to purchase services through AWS Marketplace. To learn more about becoming an AWS customer, refer to [Sign up for AWS](https://portal.aws.amazon.com/billing/signup#/start). diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-ecs.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-ecs.md new file mode 100644 index 0000000000000..e308a24253b28 --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-ecs.md @@ -0,0 +1,105 @@ ++++ +title = "Activate a Grafana Enterprise license from AWS Marketplace on ECS" +description = "Activate a Grafana Enterprise license from AWS Marketplace on ECS" +keywords = ["grafana", "ecs", "enterprise", "aws", "marketplace", "activate"] +aliases = ["/docs/grafana/latest/enterprise/activate-aws-marketplace-license/activate-license-on-ecs"] +weight = 250 ++++ + +# Activate a Grafana Enterprise license from AWS Marketplace on ECS + +If you have purchased a Grafana Enterprise subscription through AWS Marketplace, you must activate it in order to use Grafana Enterprise data source plugins and features in Grafana. + +## Before you begin + +- Purchase a subscription to [Grafana Enterprise from AWS Marketplace](https://aws.amazon.com/marketplace/pp/prodview-dlncd4kzt5kx6). +- Be sure that the IAM user that was used to purchase Grafana Enterprise has permission to manage subscriptions, create new IAM users and roles, and create access policies. + +To activate your license, complete the following tasks. + +## Task 1: Deploy Grafana Enterprise on Amazon ECS + +1. Deploy Grafana Enterprise on Amazon ECS. + + For more information about deploying an application on Amazon ECS, refer to [Creating an Amazon ECS service](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/create-service.html). + +1. As you create the Amazon ECS service, use the Grafana Enterprise version 8.3.0 or later container image. + + For example, enter `grafana/grafana-enterprise:8.3.3`. + + > Only Grafana Enterprise versions 8.3.0 and later support licenses granted through AWS Marketplace. + +## Task 2: Configure Grafana for high availability + +Grafana requires that you configure a database to hold dashboards, users, and other persistent data. + +### Before you begin + +- Ensure that you have a supported Grafana database available. + - For a list of supported databases, refer to [Supported databases]({{< relref "../../../installation/requirements.md#supported-databases" >}}). + - For information about creating a database, refer to [Creating an Amazon RDS DB instance](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CreateDBInstance.html). +- Review the information required to connect to the RDS DB instance. For more information, refer to [Connecting to an Amazon RDS DB instance](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_CommonTasks.Connect.html). + +To configure Grafana for high availability: + +1. In AWS ECS, use environment variables to update the `database` parameters. + + For a list of database parameters, refer to [Configuration]({{< relref "../../../administration/configuration.md#database" >}}). + +1. Create a revision of the task definition for the ECS Task that runs Grafana Enterprise. + + For more information about creating a task, refer to [Updating a task definition using the classic console](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/update-task-definition.html). + +1. Within the new revision, edit the Grafana Enterprise container for this task, and add the following environment variables to the container: + + ``` + GF_DATABASE_TYPE=[database type] + GF_DATABASE_HOST=[database address and port] + GF_DATABASE_NAME=[database name] + GF_DATABASE_USER=[database username] + GF_DATABASE_PASSWORD=[database password] + ``` + +> For more information about how to update your ECS service with an environment variable, refer to [Updating a service using the new console](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/update-service-console-v2.html). + +## Task 3: Configure Grafana Enterprise to validate its license with AWS + +In this task you configure Grafana Enterprise to validate the license with AWS instead of Grafana Labs. + +1. In AWS IAM, create an access policy with the following permissions: + + - `"license-manager:CheckoutLicense"` + - `"license-manager:ListReceivedLicenses"` + - `"license-manager:GetLicenseUsage"` + - `"license-manager:CheckInLicense"` + + For more information about creating an access policy, refer to [Creating IAM policies (console)](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_create-console.html). + + For more information about AWS license permissions, refer to [Actions, resources, and condition keys for AWS License Manager](https://docs.aws.amazon.com/service-authorization/latest/reference/list_awslicensemanager.html). + +1. Create an Elastic Container Service task role and attach the policy you created in the previous step. + + For more information about creating a task role, refer to [IAM Roles for Tasks](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html). + +1. Create a revision of the task definition for the ECS Task that runs Grafana Enterprise. + + For more information about creating a revision of the task definition, refer to [Updating a task definition using the classic console](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/update-task-definition.html). + +1. Within the new revision, perform the following steps: + + a. Update the Task Role of your ECS Task to the role that you created, with permission to access license information. + + b. Edit the Grafana Enterprise container for this task, and add the following environment variable to the container: + + ``` + GF_ENTERPRISE_LICENSE_VALIDATION_TYPE=aws + ``` + + For more information about how to update your ECS service with an environment variable, refer to [Updating a service using the new console](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/update-service-console-v2.html). + +### Task 4: Start or restart Grafana + +1. To restart Grafana and activate your license, update the service running Grafana to use the latest revision of the task definition that you created. +1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to the **Statistics and Licensing** page to validate that your license is active. + +For more information about validating that your license is active, refer to [Determine the number of active users for each licensed role](../../license-restrictions/#determine-the-number-of-active-users-for-each-licensed-role). diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-eks.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-eks.md new file mode 100644 index 0000000000000..aa3800ad75db4 --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-eks.md @@ -0,0 +1,118 @@ ++++ +title = "Activate a Grafana Enterprise license from AWS Marketplace on EKS" +description = "Activate a Grafana Enterprise license from AWS Marketplace on EKS" +keywords = ["grafana", "enterprise", "aws", "marketplace", "eks", "activate"] +aliases = ["/docs/grafana/latest/enterprise/activate-aws-marketplace-license/activate-license-on-eks"] +weight = 200 ++++ + +# Activate a Grafana Enterprise license from AWS Marketplace on EKS + +If you have purchased a Grafana Enterprise subscription through AWS Marketplace, you must activate it in order to use Grafana Enterprise data source plugins and features in Grafana. + +## Before you begin: + +- Purchase a subscription to [Grafana Enterprise from AWS Marketplace](https://aws.amazon.com/marketplace/pp/prodview-dlncd4kzt5kx6). +- Be sure that the IAM user that was used to purchase Grafana Enterprise has permission to manage subscriptions, create new IAM users and roles, and create access policies. + +To activate your license, complete the following tasks: + +## Task 1: Deploy Grafana Enterprise on Amazon EKS + +1. Deploy Grafana Enterprise on Amazon EKS. + + For more information about deploying an application on Amazon EKS, refer to [Getting started with Amazon EKS – AWS Management Console and AWS CLI](https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html). + + For more information about installing Grafana on Kubernetes using the Helm Chart, refer to the [Grafana Helm Chart](https://github.com/grafana/helm-charts/tree/main/charts/grafana#readme). + +1. Use `kubectl set image deployment/my-release grafana=grafana/grafana-enterprise:` to update the container image to Grafana Enterprise version 8.3.0 or later. + + For example, enter `grafana/grafana-enterprise:8.3.3`. + +> Only Grafana Enterprise versions 8.3.0 and later support licenses granted through AWS Marketplace. + +## Task 2: Configure Grafana for high availability + +Grafana requires that you configure a database to hold dashboards, users, and other persistent data. + +### Before you begin + +- Ensure that you have a supported Grafana database available. + - For a list of supported databases, refer to [Supported databases]({{< relref "../../../installation/requirements.md#supported-databases" >}}). + - For information about creating a database, refer to [Creating an Amazon RDS DB instance](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CreateDBInstance.html). +- Review the information required to connect to the RDS DB instance. For more information, refer to [Connecting to an Amazon RDS DB instance](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_CommonTasks.Connect.html). + +To configure Grafana for high availability, choose **one** of the following options: + +- **Option 1:** Use `kubectl edit configmap grafana` to edit `grafana.ini` add the following section to the configuration: + + ``` + [database] + type = [database type] + host = [database address and port] + name = [database name] + user = [database username] + password = [database password] + ``` + +- **Option 2:** use `kubectl edit deployment my-release` to edit the pod `env` variables and add the following database variables: + + ``` + - name: GF_DATABASE_TYPE + value: [database type] + - name: GF_DATABASE_HOST + value: [database address and port] + - name: GF_DATABASE_NAME + value: [database name] + - name: GF_DATABASE_USER + value: [database username] + - name: GF_DATABASE_PASSWORD + value: [database password] + ``` + +For more information on Grafana High Availability setup, refer to [Set up Grafana for high availability]({{< relref "../../../administration/set-up-for-high-availability.md" >}}). + +## Task 3: Configure Grafana Enterprise to validate its license with AWS + +In this task, you configure Grafana Enterprise to validate the license with AWS instead of Grafana Labs. + +1. In AWS IAM, assign the following permissions to the Node IAM role (if you are using a Node Group), or the Pod Execution role (if you are using a Fargate profile): + + - `"license-manager:CheckoutLicense"` + - `"license-manager:ListReceivedLicenses"` + - `"license-manager:GetLicenseUsage"` + - `"license-manager:CheckInLicense"` + + For more information about creating an access policy, refer to [Creating IAM policies (console)](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_create-console.html). + + For more information about AWS license permissions, refer to [Actions, resources, and condition keys for AWS License Manager](https://docs.aws.amazon.com/service-authorization/latest/reference/list_awslicensemanager.html). + +1. Choose **one** of the following options to update the [license_validation_type]({{< relref "../../enterprise-configuration.md#license_validation_type" >}}) configuration to `aws`: + + - **Option 1:** Use `kubectl edit configmap grafana` to edit `grafana.ini` add the following section to the configuration: + + ``` + [enterprise] + license_validation_type=aws + ``` + + - **Option 2:** Use `kubectl edit deployment my-release` to edit the pod `env` variables and add the following variable: + + ``` + name: GF_ENTERPRISE_LICENSE_VALIDATION_TYPE + value: aws + ``` + +### Task 4: Start or restart Grafana + +To activate Grafana Enterprise features, you must start (or restart) Grafana. + +To restart Grafana on a Kubernetes cluster, + +1. Run the command `kubectl rollout restart deployment my-release`. + +1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to the Statistics and Licensing page to validate that your license is active. + +For more information about restarting Grafana, refer to [Restart Grafana]({{< relref "../../../installation/restart-grafana" >}}). + +> If you experience issues when you update the EKS cluster, refer to [Amazon EKS troubleshooting](https://docs.aws.amazon.com/eks/latest/userguide/troubleshooting.html). diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-instance-outside-aws.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-instance-outside-aws.md new file mode 100644 index 0000000000000..7005d31a9c6b3 --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/activate-license-on-instance-outside-aws.md @@ -0,0 +1,121 @@ ++++ +title = "Activate a Grafana Enterprise license from AWS on an instance deployed outside of AWS" +description = "Activate a Grafana Enterprise license from AWS on an instance deployed outside of AWS" +keywords = ["grafana", "enterprise", "aws", "marketplace", "activate"] +aliases = ["/docs/grafana/latest/enterprise/activate-aws-marketplace-license/activate-license-on-instance-outside-aws"] +weight = 300 ++++ + +# Activate a Grafana Enterprise license from AWS on an instance deployed outside of AWS + +While AWS Marketplace lists ECS and EKS as the supported environments for Grafana Enterprise, you can apply a Grafana Enterprise license from AWS Marketplace to any Grafana instance with network access to the AWS licensing service. + +## Before you begin + +- Purchase a subscription to [Grafana Enterprise from AWS Marketplace](https://aws.amazon.com/marketplace/pp/prodview-dlncd4kzt5kx6). +- Be sure that the IAM user that was used to purchase Grafana Enterprise has permission to manage subscriptions, create new IAM users, and create access policies. +- Be sure there is network access between AWS and the environment where you intend to run Grafana. Network access is required because your Grafana instance communicates with the [AWS License Manager endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/licensemanager.html) to retrieve license and subscription information. Grafana instances with access to the public internet will have access to AWS license manager. + +To activate a Grafana Enterprise license from AWS on a Grafana Enterprise instance deployed outside of AWS, complete the following tasks. + +## Task 1: Install Grafana Enterprise + +To install Grafana, refer to the documentation specific to your implementation. + +- [Install Grafana]({{< relref "../../../installation/" >}}). +- [Run Grafana Docker image]({{< relref "../../../installation/docker" >}}). +- [Deploy Grafana on Kubernetes]({{< relref "../../../installation/kubernetes/#deploy-grafana-enterprise-on-kubernetes" >}}). + +## Task 2: Create an AWS IAM user with access to your Grafana Enterprise license + +To retrieve your license, Grafana Enterprise requires access to your AWS account and license information. To grant access, create an IAM user in AWS with access to the license, and pass its credentials as environment variables on the host or container where Grafana is running. These environment variables allow Grafana to retrieve license details from AWS. + +1. In the AWS License Manager service, create an IAM policy with the following permissions: + + - `"license-manager:CheckoutLicense"` + - `"license-manager:ListReceivedLicenses"` + - `"license-manager:GetLicenseUsage"` + - `"license-manager:CheckInLicense"` + + For more information about creating a policy in AWS, refer to [Creating IAM policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_create.html). + + For more information about AWS Identity and Access Management, refer to [IAM users](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users.html). + +1. To limit the policy to obtain usage data just for Grafana Enterprise, in the **Resources** section of the policy, specify your license ARN. + + You can find your license ID in the **Granted Licenses** section of [AWS License Manager](https://console.aws.amazon.com/license-manager/home). + + The policy JSON should look similar to the following example: + + ``` + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": "license-manager:GetLicenseUsage", + "Resource": "arn:aws:license-manager::[YOUR_ACCOUNT]:license:[YOUR_LICENSE_ID]" + }, + { + "Sid": "VisualEditor1", + "Effect": "Allow", + "Action": [ + "license-manager:CheckoutLicense", + "license-manager:ListReceivedLicenses", + "license-manager:CheckInLicense" + ], + "Resource": "*" + } + ] + } + ``` + +1. Create an IAM user and choose access key credentials as its authentication method. + + For more information about creating an IAM user, refer to [IAM users](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users.html). + + For more information about access key credentials, refer to [Managing access keys for IAM users](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html). + +1. Attach the policy you created to the IAM user. + +1. Add the following values as environment variables to the host or container running Grafana: + + - AWS region + - IAM user's access key ID + - IAM user's secret access key + + The environment variables should look similar to the following example: + + ``` + AWS_ACCESS_KEY_ID=ABCD5E75FGHIJKTM7 + AWS_SECRET_ACCESS_KEY=k8fhYAQVy+5NhCejhe6HeSjSphjRuy+12C06 + AWS_REGION=us-east-1 + ``` + +## Task 3: Configure Grafana Enterprise to validate its license with AWS + +In this task you configure Grafana Enterprise to validate the license with AWS instead of Grafana Labs. + +Choose one of the following options to update the [license_validation_type]({{< relref "../../enterprise-configuration.md#license_validation_type" >}}) configuration to `aws`: + +- **Option 1:** In the `[enterprise]` section of the grafana.ini configuration file, add `license_validation_type=aws`. + + For example: + + ``` + [enterprise] + license_validation_type=aws + ``` + +- **Option 2:** Add the following environment variable to the container or host: + + ``` + GF_ENTERPRISE_LICENSE_VALIDATION_TYPE=aws + ``` + +## Task 4: Start or restart Grafana + +To activate Grafana Enterprise features, start (or restart) Grafana. + +For information about restarting Grafana, refer to [Restart Grafana]({{< relref "../../../installation/restart-grafana" >}}). diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/manage-license-in-aws-marketplace.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/manage-license-in-aws-marketplace.md new file mode 100644 index 0000000000000..0c7b76959739e --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/manage-license-in-aws-marketplace.md @@ -0,0 +1,31 @@ ++++ +title = "Manage your Grafana Enterprise license in AWS Marketplace" +description = "Manage your Grafana Enterprise license in AWS Marketplace" +keywords = ["grafana", "enterprise", "aws", "marketplace", "manage", "add", "remove", "users"] +aliases = ["/docs/grafana/latest/enterprise/activate-aws-marketplace-license/manage-license-in-aws-marketplace"] +weight = 400 ++++ + +## Manage your Grafana Enterprise license in AWS Marketplace + +You can use AWS Marketplace to make the following modifications to your Grafana Enterprise license: + +- Add active users +- Remove active users +- Cancel your subscription to Grafana Enterprise. + +**To modify your Grafana Enterprise subscription in AWS Marketplace:** + +1. Open the AWS Console and navigate to [Subscription Management](https://console.aws.amazon.com/marketplace/home/subscriptions#/subscriptions). + +1. Update your license. + +1. Sign in to Grafana as a Server Administrator. + +1. Hover over **Server Admin** in the navigation bar and click **Statistics and Licensing**. + +1. In the **Token** section under **Enterprise License**, click **Renew License**. + + This action retrieves updated license information from AWS. + +> To learn more about licensing and active users, refer to [Understanding Grafana Enterprise licensing]({{< relref "../../license/license-restrictions" >}}). diff --git a/docs/sources/enterprise/license/activate-aws-marketplace-license/transfer-ge-license.md b/docs/sources/enterprise/license/activate-aws-marketplace-license/transfer-ge-license.md new file mode 100644 index 0000000000000..22c71027c552a --- /dev/null +++ b/docs/sources/enterprise/license/activate-aws-marketplace-license/transfer-ge-license.md @@ -0,0 +1,22 @@ ++++ +title = "Transfer your AWS Marketplace Grafana Enterprise license" +description = "Transfer your AWS Marketplace Grafana Enterprise license" +keywords = ["grafana", "enterprise", "aws", "marketplace", "transfer", "move"] +aliases = ["/docs/grafana/latest/enterprise/activate-aws-marketplace-license/transfer-ge-license"] +weight = 400 ++++ + +# Transfer your AWS Marketplace Grafana Enterprise license + +You can transfer your AWS Marketplace Grafana Enterprise license to another Grafana Enterprise instance. The transfer process requires that you first remove your license from one instance, and then apply the license to another instance. + +> When you remove an Enterprise license, the system immediately disables all Grafana Enterprise features. + +To remove an Enterprise license from a Grafana Enterprise instance, perform one of the following steps: + +- If you are using Amazon ECS or Amazon EKS, remove the `GF_ENTERPRISE_LICENSE_VALIDATION_TYPE` environment variable from the container. +- If you have deployed Grafana Enterprise outside of AWS, remove the `aws` license_validation_type value from the grafana.ini configuration file. + +It can take the system up to one hour to clear the license. After the system clears the license, you can apply the same license to another Grafana Enterprise instance. + +To determine that the system has returned your license, check the license details in AWS License Manager. diff --git a/docs/sources/enterprise/recorded-queries.md b/docs/sources/enterprise/recorded-queries.md index 012a3b695cda1..2990f0bf46bef 100644 --- a/docs/sources/enterprise/recorded-queries.md +++ b/docs/sources/enterprise/recorded-queries.md @@ -13,7 +13,7 @@ For our plugins that do not return time series, it might be useful to plot histo ## How recorded queries work -**> Note:** An administrator must configure a Prometheus data source and associate it with a [Remote write target](#remote-write-target) before recorded queries can be used. +> **Note:** An administrator must configure a Prometheus data source and associate it with a [Remote write target](#remote-write-target) before recorded queries can be used. Recorded queries only work with backend data source plugins. Refer to [Backend data source plugin](https://grafana.com/tutorials/build-a-data-source-backend-plugin/) for more information about backend data source plugins. You can recorded three types of queries: diff --git a/docs/sources/http_api/access_control.md b/docs/sources/http_api/access_control.md index a69718203a079..4762dc30416eb 100644 --- a/docs/sources/http_api/access_control.md +++ b/docs/sources/http_api/access_control.md @@ -86,23 +86,27 @@ Content-Type: application/json; charset=UTF-8 [ { - "version": 1, - "uid": "Kz9m_YjGz", - "name": "fixed:reporting:admin:edit", - "description": "Gives access to edit any report or the organization's general reporting settings.", - "global": true, - "updated": "2021-05-13T16:24:26+02:00", - "created": "2021-05-13T16:24:26+02:00" + "version": 3, + "uid": "XvHQJq57z", + "name": "fixed:reports:reader", + "displayName": "Report reader", + "description": "Read all reports and shared report settings.", + "group": "Reports", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00", + "global": false }, { - "version": 5, - "uid": "vi9mlLjGz", - "name": "fixed:permissions:admin:read", - "description": "Gives access to read and list roles and permissions, as well as built-in role assignments.", - "global": true, - "updated": "2021-05-13T22:41:49+02:00", - "created": "2021-05-13T16:24:26+02:00" - } + "version": 4, + "uid": "6dNwJq57z", + "name": "fixed:reports:writer", + "displayName": "Report writer", + "description": "Create, read, update, or delete all reports and shared report settings.", + "group": "Reports", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00", + "global": false + }, ] ``` @@ -141,27 +145,59 @@ HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 { - "version": 2, - "uid": "jZrmlLCGk", - "name": "fixed:permissions:admin:edit", - "description": "Gives access to create, update and delete roles, as well as manage built-in role assignments.", - "global": true, + "version": 4, + "uid": "6dNwJq57z", + "name": "fixed:reports:writer", + "displayName": "Report writer", + "description": "Create, read, update, or delete all reports and shared report settings.", + "group": "Reports", "permissions": [ { - "action": "roles:delete", - "scope": "permissions:delegate", - "updated": "2021-05-13T16:24:26+02:00", - "created": "2021-05-13T16:24:26+02:00" + "action": "reports:delete", + "scope": "reports:*", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" }, { - "action": "roles:list", - "scope": "roles:*", - "updated": "2021-05-13T16:24:26+02:00", - "created": "2021-05-13T16:24:26+02:00" + "action": "reports:read", + "scope": "reports:*", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" + }, + { + "action": "reports:send", + "scope": "reports:*", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" + }, + { + "action": "reports.admin:create", + "scope": "", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" + }, + { + "action": "reports.admin:write", + "scope": "reports:*", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" + }, + { + "action": "reports.settings:read", + "scope": "", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" + }, + { + "action": "reports.settings:write", + "scope": "", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00" } ], - "updated": "2021-05-13T16:24:26+02:00", - "created": "2021-05-13T16:24:26+02:00" + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00", + "global": false } ``` @@ -170,7 +206,7 @@ Content-Type: application/json; charset=UTF-8 | Code | Description | | ---- | -------------------------------------------------------------------- | | 200 | Role is returned. | -| 403 | Access denied | +| 403 | Access denied. | | 500 | Unexpected error. Refer to body and/or server logs for more details. | ### Create a new custom role @@ -200,7 +236,9 @@ Content-Type: application/json "uid": "jZrmlLCGka", "name": "custom:delete:roles", "description": "My custom role which gives users permissions to delete roles", - "global": true, + "group":"My Group", + "displayName": "My Custom Role", + "global": false, "permissions": [ { "action": "roles:delete", @@ -219,6 +257,8 @@ Content-Type: application/json | version | number | No | Version of the role. If not present, version 0 will be assigned to the role and returned in the response. Refer to the [Custom roles]({{< relref "../enterprise/access-control/roles.md#custom-roles" >}}) for more information. | | name | string | Yes | Name of the role. Refer to [Custom roles]({{< relref "../enterprise/access-control/roles.md#custom-roles" >}}) for more information. | | description | string | No | Description of the role. | +| displayName | string | No | Display name of the role, visible in the UI. | +| group | string | No | The group name the role belongs to. | | permissions | Permission | No | If not present, the role will be created without any permissions. | **Permission** @@ -239,7 +279,9 @@ Content-Type: application/json; charset=UTF-8 "uid": "jZrmlLCGka", "name": "custom:delete:create:roles", "description": "My custom role which gives users permissions to delete and create roles", - "global": true, + "group":"My Group", + "displayName": "My Custom Role", + "global": false, "permissions": [ { "action": "roles:delete", @@ -288,7 +330,9 @@ Content-Type: application/json "version": 3, "name": "custom:delete:write:roles", "description": "My custom role which gives users permissions to delete and write roles", - "global": true, + "group":"My Group", + "displayName": "My Custom Role", + "global": false, "permissions": [ { "action": "roles:delete", @@ -309,6 +353,8 @@ Content-Type: application/json | version | number | Yes | Version of the role. Must be incremented for update to work. | | name | string | Yes | Name of the role. | | description | string | No | Description of the role. | +| displayName | string | No | Display name of the role, visible in the UI. | +| group | string | No | The group name the role belongs to. | | permissions | List of Permissions | No | The full list of permissions the role should have after the update. | **Permission** @@ -329,6 +375,8 @@ Content-Type: application/json; charset=UTF-8 "uid":"jZrmlLCGka", "name":"custom:delete:write:roles", "description":"My custom role which gives users permissions to delete and write roles", + "group":"My Group", + "displayName": "My Custom Role", "permissions":[ { "action":"roles:delete", @@ -345,7 +393,7 @@ Content-Type: application/json; charset=UTF-8 ], "updated":"2021-08-06T18:27:41+02:00", "created":"2021-08-06T18:27:40+02:00", - "global":true + "global":false } ``` @@ -377,7 +425,7 @@ For example, if a user does not have required permissions for creating users, th #### Example request ```http -DELETE /api/access-control/roles/jZrmlLCGka?force=true&global=true +DELETE /api/access-control/roles/jZrmlLCGka?force=true&global=false Accept: application/json ``` @@ -407,6 +455,275 @@ Content-Type: application/json; charset=UTF-8 | 403 | Access denied | | 500 | Unexpected error. Refer to body and/or server logs for more details. | +## Create and remove user role assignments + +### List roles assigned to a user + +`GET /api/access-control/users/:userId/roles` + +Lists the roles that have been directly assigned to a given user. The list does not include built-in roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team. + +#### Required permissions + +| Action | Scope | +| ---------------- | -------------------- | +| users.roles:list | users:id:`` | + +#### Example request + +```http +GET /api/access-control/users/1/roles +Accept: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +[ + { + "version": 4, + "uid": "6dNwJq57z", + "name": "fixed:reports:writer", + "displayName": "Report writer", + "description": "Create, read, update, or delete all reports and shared report settings.", + "group": "Reports", + "updated": "2021-11-19T10:48:00+01:00", + "created": "2021-11-19T10:48:00+01:00", + "global": false + } +] +``` + +#### Status codes + +| Code | Description | +| ---- | -------------------------------------------------------------------- | +| 200 | Set of assigned roles is returned. | +| 403 | Access denied. | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | + +### List permissions assigned to a user + +`GET /api/access-control/users/:userId/permissions` + +Lists the permissions that a given user has. + +#### Required permissions + +| Action | Scope | +| ---------------------- | -------------------- | +| users.permissions:list | users:id:`` | + +#### Example request + +```http +GET /api/access-control/users/1/permissions +Accept: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +[ + { + "action": "ldap.status:read", + "scope": "" + }, + { + "action": "ldap.user:read", + "scope": "" + } +] +``` + +#### Status codes + +| Code | Description | +| ---- | -------------------------------------------------------------------- | +| 200 | Set of assigned permissions is returned. | +| 403 | Access denied. | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | + +### Add a user role assignment + +`POST /api/access-control/users/:userId/roles` + +Assign a role to a specific user. + +For bulk updates consider +[Set user role assignments]({{< ref "#set-user-role-assignments" >}}). + +#### Required permissions + +`permission:delegate` scope ensures that users can only assign roles which have same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to assign a role which will allow to do that. This is done to prevent escalation of privileges. + +| Action | Scope | +| --------------- | -------------------- | +| users.roles:add | permissions:delegate | + +#### Example request + +```http +POST /api/access-control/users/1/roles +Accept: application/json +Content-Type: application/json + +{ + "global": false, + "roleUid": "XvHQJq57z" +} +``` + +#### JSON body schema + +| Field Name | Data Type | Required | Description | +| ---------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| roleUid | string | Yes | UID of the role. | +| global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request to create organization local assignment. | + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "message": "Role added to the user." +} +``` + +#### Status codes + +| Code | Description | +| ---- | -------------------------------------------------------------------- | +| 200 | Role is assigned to a user. | +| 403 | Access denied. | +| 404 | Role not found. | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | + +## Remove a user role assignment + +`DELETE /api/access-control/users/:userId/roles/:roleUID` + +Revoke a role from a user. + +For bulk updates consider +[Set user role assignments]({{< ref "#set-user-role-assignments" >}}). + +#### Required permissions + +`permission:delegate` scope ensures that users can only unassign roles which have same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to unassign a role which will allow to do that. This is done to prevent escalation of privileges. + +| Action | Scope | +| ------------------ | -------------------- | +| users.roles:remove | permissions:delegate | + +#### Query parameters + +| Param | Type | Required | Description | +| ------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request to remove assignment. | + +#### Example request + +```http +DELETE /api/access-control/users/1/roles/AFUXBHKnk +Accept: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "message": "Role removed from user." +} +``` + +#### Status codes + +| Code | Description | +| ---- | -------------------------------------------------------------------- | +| 200 | Role is unassigned. | +| 403 | Access denied. | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | + +### Set user role assignments + +`PUT /api/access-control/users/:userId/roles` + +Update the user's role assignments to match the provided set of UIDs. +This will remove any assigned roles that aren't in the request and add +roles that are in the set but are not already assigned to the user. + +If you want to add or remove a single role, consider using +[Add a user role assignment]({{< ref "#add-a-user-role-assignment" >}}) or +[Remove a user role assignment]({{< ref "#remove-a-user-role-assignment" >}}) +instead. + +#### Required permissions + +`permission:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges. + +| Action | Scope | +| ------------------ | -------------------- | +| users.roles:add | permissions:delegate | +| users.roles:remove | permissions:delegate | + +#### Example request + +```http +PUT /api/access-control/users/1/roles +Accept: application/json +Content-Type: application/json + +{ + "global": false, + "roleUids": [ + "ZiHQJq5nk", + "GzNQ1357k" + ] +} +``` + +#### JSON body schema + +| Field Name | Date Type | Required | Description | +| ---------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request. | +| roleUids | list | Yes | List of role UIDs. | + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "message": "User roles have been updated." +} +``` + +#### Status codes + +| Code | Description | +| ---- | -------------------------------------------------------------------- | +| 200 | Roles have been assigned. | +| 403 | Access denied. | +| 404 | Role not found. | +| 500 | Unexpected error. Refer to body and/or server logs for more details. | + ## Create and remove built-in role assignments API set allows to create or remove [built-in role assignments]({{< relref "../enterprise/access-control/roles.md#built-in-role-assignments" >}}) and list current assignments. @@ -444,7 +761,7 @@ Content-Type: application/json; charset=UTF-8 "uid": "qQui_LCMk", "name": "fixed:users:org:edit", "description": "", - "global": true, + "global": false, "updated": "2021-05-13T16:24:26+02:00", "created": "2021-05-13T16:24:26+02:00" }, @@ -453,7 +770,7 @@ Content-Type: application/json; charset=UTF-8 "uid": "PeXmlYjMk", "name": "fixed:users:org:read", "description": "", - "global": true, + "global": false, "updated": "2021-05-13T16:24:26+02:00", "created": "2021-05-13T16:24:26+02:00" } @@ -464,7 +781,7 @@ Content-Type: application/json; charset=UTF-8 "uid": "qQui_LCMk", "name": "fixed:users:org:edit", "description": "", - "global": true, + "global": false, "updated": "2021-05-13T16:24:26+02:00", "created": "2021-05-13T16:24:26+02:00" } diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index 16da11450dca0..f2d963d7baceb 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -20,6 +20,14 @@ the admin of all organizations API only works with basic authentication, see [Ad `GET /api/org/` +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | +| --------- | ----- | +| orgs:read | N/A | + **Example Request**: ```http @@ -94,6 +102,14 @@ Accessible to users with org admin role, admin in any folder or admin of any tea Mainly used by Grafana UI for providing list of users when adding team members and when editing folder/dashboard permissions. +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | +| -------------- | -------- | +| org.users:read | users:\* | + **Example Request**: ```http @@ -186,6 +202,14 @@ Content-Type: application/json `PUT /api/org` +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | +| ---------- | ----- | +| orgs:write | N/A | + **Example Request**: ```http @@ -259,6 +283,14 @@ is called `admin` and has permission to use this API). Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | +| --------- | ----- | +| orgs:read | N/A | + **Example Request**: ```http @@ -293,6 +325,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | Note | +| --------- | ----- | ------------------------------ | +| orgs:read | N/A | Needs to be assigned globally. | + **Example Request**: ```http @@ -327,6 +367,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | Note | +| ----------- | ----- | ------------------------------ | +| orgs:create | N/A | Needs to be assigned globally. | + **Example Request**: ```http @@ -362,6 +410,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | Note | +| --------- | ----- | ------------------------------ | +| orgs:read | N/A | Needs to be assigned globally. | + **Example Request**: ```http @@ -396,6 +452,14 @@ Content-Type: application/json Update Organization, fields _Address 1_, _Address 2_, _City_ are not implemented yet. Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | +| ---------- | ----- | +| orgs:write | N/A | + **Example Request**: ```http @@ -423,6 +487,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +| Action | Scope | +| ----------- | ----- | +| orgs:delete | N/A | + **Example Request**: ```http diff --git a/docs/sources/http_api/team.md b/docs/sources/http_api/team.md index d9d0135fdc6cf..f530a6f2cf485 100644 --- a/docs/sources/http_api/team.md +++ b/docs/sources/http_api/team.md @@ -7,7 +7,13 @@ aliases = ["/docs/grafana/latest/http_api/team/"] # Team API -This API can be used to create/update/delete Teams and to add/remove users to Teams. All actions require that the user has the Admin role for the organization. +This API can be used to manage Teams and Team Memberships. + +Access to these API endpoints is restricted as follows: + +- All authenticated users are able to view details of teams they are a member of. +- Organization Admins are able to manage all teams and team members. +- If the `editors_can_admin` configuration flag is enabled, Organization Editors are able to view details of all teams and to manage teams that they are Admin members of. ## Team Search With Paging diff --git a/docs/sources/image-rendering/_index.md b/docs/sources/image-rendering/_index.md index 8bb9130431acb..93efc8bccde8a 100644 --- a/docs/sources/image-rendering/_index.md +++ b/docs/sources/image-rendering/_index.md @@ -72,7 +72,7 @@ RENDERING_MODE=default #### Clustered -With the `clustered` mode, you can configure how many browser instances or incognito pages can execute concurrently. Default is `browser` and will ensure a maximum amount of browser instances can execute concurrently. Mode `context` will ensure a maximum amount of incognito pages can execute concurrently. You can also configure the maximum concurrency allowed, which per default is `5`. +With the `clustered` mode, you can configure how many browser instances or incognito pages can execute concurrently. Default is `browser` and will ensure a maximum amount of browser instances can execute concurrently. Mode `context` will ensure a maximum amount of incognito pages can execute concurrently. You can also configure the maximum concurrency allowed, which per default is `5`, and the maximum duration of a rendering request, which per default is `30` seconds. Using a cluster of incognito pages is more performant and consumes less CPU and memory than a cluster of browsers. However, if one page crashes it can bring down the entire browser with it (making all the rendering requests happening at the same time fail). Also, each page isn't guaranteed to be totally clean (cookies and storage might bleed-through as seen [here](https://bugs.chromium.org/p/chromium/issues/detail?id=754576)). @@ -80,6 +80,7 @@ Using a cluster of incognito pages is more performant and consumes less CPU and RENDERING_MODE=clustered RENDERING_CLUSTERING_MODE=browser RENDERING_CLUSTERING_MAX_CONCURRENCY=5 +RENDERING_CLUSTERING_TIMEOUT=30 ``` ```json @@ -88,7 +89,8 @@ RENDERING_CLUSTERING_MAX_CONCURRENCY=5 "mode": "clustered", "clustering": { "mode": "browser", - "maxConcurrency": 5 + "maxConcurrency": 5, + "timeout": 30 } } } diff --git a/docs/sources/image-rendering/troubleshooting.md b/docs/sources/image-rendering/troubleshooting.md index 20cdae5fdcd47..0b789834ed653 100644 --- a/docs/sources/image-rendering/troubleshooting.md +++ b/docs/sources/image-rendering/troubleshooting.md @@ -55,34 +55,40 @@ ldd chrome-linux/chrome **Ubuntu:** -On Ubuntu 18.10 the following dependencies have been confirmed as needed for the image rendering to function. +On Ubuntu 18.10 the following dependencies are required for the image rendering to function. ```bash -libx11-6 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrender1 libxtst6 libglib2.0-0 libnss3 libcups2 libdbus-1-3 libxss1 libxrandr2 libgtk-3-0 libgtk-3-0 libasound2 libxcb-dri3-0 libgbm1 libxshmfence1 +libx11-6 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrender1 libxtst6 libglib2.0-0 libnss3 libcups2 libdbus-1-3 libxss1 libxrandr2 libgtk-3-0 libasound2 libxcb-dri3-0 libgbm1 libxshmfence1 ``` **Debian:** -On Debian 9 (Stretch) the following dependencies have been confirmed as needed for the image rendering to function. +On Debian 9 (Stretch) the following dependencies are required for the image rendering to function. ```bash -libx11 libcairo bcairo2 libcairo2 libxtst6 libxcomposite1 libx11-xcb1 libxcursor1 libxdamage1 libnss3 libcups libcups2 libXss libXss1 libxss1 libxrandr2 libasound2 libatk1.0-0 libatk-bridge2.0-0 libpangocairo-1.0-0 libgtk-3-0 libgbm1 libxshmfence1 +libx11 libcairo libcairo2 libxtst6 libxcomposite1 libx11-xcb1 libxcursor1 libxdamage1 libnss3 libcups libcups2 libxss libxss1 libxrandr2 libasound2 libatk1.0-0 libatk-bridge2.0-0 libpangocairo-1.0-0 libgtk-3-0 libgbm1 libxshmfence1 ``` -On Debian 10 (Buster) the following dependencies have been confirmed as needed for the image rendering to function. +On Debian 10 (Buster) the following dependencies are required for the image rendering to function. ```bash -libxdamage1 libxext6 libxi6 libxtst6 libnss3 libnss3 libcups2 libxss1 libxrandr2 libasound2 libatk1.0-0 libatk-bridge2.0-0 libpangocairo-1.0-0 libpango-1.0-0 libcairo2 libatspi2.0-0 libgtk3.0-cil libgdk3.0-cil libx11-xcb-dev libgbm1 libxshmfence1 +libxdamage1 libxext6 libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 libasound2 libatk1.0-0 libatk-bridge2.0-0 libpangocairo-1.0-0 libpango-1.0-0 libcairo2 libatspi2.0-0 libgtk3.0-cil libgdk3.0-cil libx11-xcb-dev libgbm1 libxshmfence1 ``` **Centos:** -On a minimal Centos installation, the following dependencies have been confirmed as needed for the image rendering to function: +On a minimal CentOS 7 installation, the following dependencies are required for the image rendering to function: ```bash libXcomposite libXdamage libXtst cups libXScrnSaver pango atk adwaita-cursor-theme adwaita-icon-theme at at-spi2-atk at-spi2-core cairo-gobject colord-libs dconf desktop-file-utils ed emacs-filesystem gdk-pixbuf2 glib-networking gnutls gsettings-desktop-schemas gtk-update-icon-cache gtk3 hicolor-icon-theme jasper-libs json-glib libappindicator-gtk3 libdbusmenu libdbusmenu-gtk3 libepoxy liberation-fonts liberation-narrow-fonts liberation-sans-fonts liberation-serif-fonts libgusb libindicator-gtk3 libmodman libproxy libsoup libwayland-cursor libwayland-egl libxkbcommon m4 mailx nettle patch psmisc redhat-lsb-core redhat-lsb-submod-security rest spax time trousers xdg-utils xkeyboard-config alsa-lib ``` +On a minimal CentOS 8 installation, the following dependencies are required for the image rendering to function: + +```bash +libXcomposite libXdamage libXtst cups libXScrnSaver pango atk adwaita-cursor-theme adwaita-icon-theme at at-spi2-atk at-spi2-core cairo-gobject colord-libs dconf desktop-file-utils ed emacs-filesystem gdk-pixbuf2 glib-networking gnutls gsettings-desktop-schemas gtk-update-icon-cache gtk3 hicolor-icon-theme jasper-libs json-glib libappindicator-gtk3 libdbusmenu libdbusmenu-gtk3 libepoxy liberation-fonts liberation-narrow-fonts liberation-sans-fonts liberation-serif-fonts libgusb libindicator-gtk3 libmodman libproxy libsoup libwayland-cursor libwayland-egl libxkbcommon m4 mailx nettle patch psmisc redhat-lsb-core redhat-lsb-submod-security rest spax time trousers xdg-utils xkeyboard-config alsa-lib libX11-xcb +``` + ## Certificate signed by internal certificate authorities In many cases, Grafana runs on internal servers and uses certificates that have not been signed by a CA ([Certificate Authority](https://en.wikipedia.org/wiki/Certificate_authority)) known to Chrome, and therefore cannot be validated. Chrome internally uses NSS ([Network Security Services](https://en.wikipedia.org/wiki/Network_Security_Services)) for cryptographic operations such as the validation of certificates. diff --git a/docs/sources/panels/add-a-panel.md b/docs/sources/panels/add-a-panel.md index d05254551846b..dbf6b0fa19fea 100644 --- a/docs/sources/panels/add-a-panel.md +++ b/docs/sources/panels/add-a-panel.md @@ -25,12 +25,11 @@ Each panel needs at least one query to display a visualization. You write querie 1. Choose a data source. In the first line of the Query tab, click the drop-down list to see all available data sources. This list includes all data sources you added. Refer to [Add a data source]({{< relref "../datasources/add-a-data-source.md" >}}) if you need instructions. 1. Write or construct a query in the query language of your data source. Options will vary. Refer to your specific [data source documentation]({{< relref "../datasources/_index.md" >}}) for specific guidelines. -## 3. Choose a visualization type - -In the Visualization list, click a visualization type. Grafana displays a preview of your query results with that visualization applied. +## 3. Choose a visualization ![](/static/img/docs/panel-editor/select-visualization-8-0.png) +When you choose a visualization, Grafana will offer visualization suggestions based on your query. You can select from the available suggestions or you can pick a visualization from the complete list of supported visualizations. When you select a visualization, Grafana will show a preview of your query results with that visualization applied. For more information about individual visualizations, refer to [Visualizations options]({{< relref "../visualizations/_index.md" >}}). ## 4. (Optional) Edit panel settings diff --git a/docs/sources/panels/queries.md b/docs/sources/panels/queries.md index 1fd4d97d0a5f1..141d138821134 100644 --- a/docs/sources/panels/queries.md +++ b/docs/sources/panels/queries.md @@ -95,6 +95,8 @@ Panel data source query options: - **Relative time -** You can override the relative time range for individual panels, causing them to be different than what is selected in the dashboard time picker in the top right corner of the dashboard. This allows you to show metrics from different time periods or days on the same dashboard. + > **Note:** Panel time overrides have no effect when the time range for the dashboard is absolute. + - **Time shift -** The time shift function is another way to override the time range for individual panels. It only works with relative time ranges and allows you to adjust the time range. For example, you could shift the time range for the panel to be two hours earlier than the dashboard time picker. For more information, refer to [Time range controls]({{< relref "../dashboards/time-range-controls.md" >}}). diff --git a/docs/sources/panels/transformations/_index.md b/docs/sources/panels/transformations/_index.md index d19e4b5e98ae7..73bea335b8a37 100644 --- a/docs/sources/panels/transformations/_index.md +++ b/docs/sources/panels/transformations/_index.md @@ -29,6 +29,7 @@ Before you can configure and apply transformations: - [Add field from calculation]({{< relref "./types-options.md#add-field-from-calculation" >}}) - [Concatenate fields]({{< relref "./types-options.md#concatenate-fields" >}}) - [Config from query results]({{< relref "./config-from-query.md" >}}) +- [Convert field type]({{< relref "./types-options.md#convert-field-type" >}}) - [Filter data by name]({{< relref "./types-options.md#filter-data-by-name" >}}) - [Filter data by query]({{< relref "./types-options.md#filter-data-by-query" >}}) - [Filter data by value]({{< relref "./types-options.md#filter-data-by-value" >}}) diff --git a/docs/sources/panels/transformations/types-options.md b/docs/sources/panels/transformations/types-options.md index 575010c8ee826..d69948f8525db 100644 --- a/docs/sources/panels/transformations/types-options.md +++ b/docs/sources/panels/transformations/types-options.md @@ -347,10 +347,10 @@ This transformation changes the field type of the specified field. - **Numeric -** attempts to make the values numbers - **String -** will make the values strings - **Time -** attempts to parse the values as time - - Will show an option to specify a DateFormat as input by a string like yyyy-mm-dd or DD MM YYYY hh:mm:ss + - Will show an option to specify a DateFormat for the input field like yyyy-mm-dd or DD MM YYYY hh:mm:ss - **Boolean -** will make the values booleans -For example the following query could be modified by selecting the time field, as Time, and Date Format as YYYY. +For example the following query could be modified by selecting the time field, as Time, and Input format as YYYY. | Time | Mark | Value | | ---------- | ----- | ----- | diff --git a/docs/sources/plugins/catalog.md b/docs/sources/plugins/catalog.md index edfe3a1e29899..f73b5e49017d5 100644 --- a/docs/sources/plugins/catalog.md +++ b/docs/sources/plugins/catalog.md @@ -23,7 +23,7 @@ The Plugin catalog allows you to browse and manage plugins from within Grafana. -Before you can use the Plugin catalog, you must enable it in the Grafana [configuration]({{< relref "../administration/configuration.md#plugin_admin_enabled" >}}) file. +In order to be able to install / uninstall / update plugins using plugin catalog, you must enable it via the `plugin_admin_enabled` flag in the [configuration]({{< relref "../administration/configuration.md#plugin_admin_enabled" >}}) file. Before following the steps below, make sure you are logged in as a Grafana administrator. diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index cae401c62321b..ccc8a7b50b231 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,18 @@ weight = 10000 Here you can find detailed release notes that list everything that is included in every release as well as notices about deprecations, breaking changes as well as changes that relate to plugin development. +- [Release notes for 8.3.7]({{< relref "release-notes-8-3-7" >}}) +- [Release notes for 8.3.5]({{< relref "release-notes-8-3-5" >}}) +- [Release notes for 8.3.4]({{< relref "release-notes-8-3-4" >}}) +- [Release notes for 8.3.3]({{< relref "release-notes-8-3-3" >}}) +- [Release notes for 8.3.2]({{< relref "release-notes-8-3-2" >}}) +- [Release notes for 8.3.1]({{< relref "release-notes-8-3-1" >}}) +- [Release notes for 8.3.0]({{< relref "release-notes-8-3-0" >}}) +- [Release notes for 8.3.0-beta2]({{< relref "release-notes-8-3-0-beta2" >}}) +- [Release notes for 8.3.0-beta1]({{< relref "release-notes-8-3-0-beta1" >}}) +- [Release notes for 8.2.7]({{< relref "release-notes-8-2-7" >}}) +- [Release notes for 8.2.6]({{< relref "release-notes-8-2-6" >}}) +- [Release notes for 8.2.5]({{< relref "release-notes-8-2-5" >}}) - [Release notes for 8.2.4]({{< relref "release-notes-8-2-4" >}}) - [Release notes for 8.2.3]({{< relref "release-notes-8-2-3" >}}) - [Release notes for 8.2.2]({{< relref "release-notes-8-2-2" >}}) @@ -15,6 +27,7 @@ about deprecations, breaking changes as well as changes that relate to plugin de - [Release notes for 8.2.0]({{< relref "release-notes-8-2-0" >}}) - [Release notes for 8.2.0-beta2]({{< relref "release-notes-8-2-0-beta2" >}}) - [Release notes for 8.2.0-beta1]({{< relref "release-notes-8-2-0-beta1" >}}) +- [Release notes for 8.1.8]({{< relref "release-notes-8-1-8" >}}) - [Release notes for 8.1.7]({{< relref "release-notes-8-1-7" >}}) - [Release notes for 8.1.6]({{< relref "release-notes-8-1-6" >}}) - [Release notes for 8.1.5]({{< relref "release-notes-8-1-5" >}}) @@ -26,6 +39,7 @@ about deprecations, breaking changes as well as changes that relate to plugin de - [Release notes for 8.1.0-beta3]({{< relref "release-notes-8-1-0-beta3" >}}) - [Release notes for 8.1.0-beta2]({{< relref "release-notes-8-1-0-beta2" >}}) - [Release notes for 8.1.0-beta1]({{< relref "release-notes-8-1-0-beta1" >}}) +- [Release notes for 8.0.7]({{< relref "release-notes-8-0-7" >}}) - [Release notes for 8.0.6]({{< relref "release-notes-8-0-6" >}}) - [Release notes for 8.0.5]({{< relref "release-notes-8-0-5" >}}) - [Release notes for 8.0.4]({{< relref "release-notes-8-0-4" >}}) @@ -36,6 +50,9 @@ about deprecations, breaking changes as well as changes that relate to plugin de - [Release notes for 8.0.0-beta3]({{< relref "release-notes-8-0-0-beta3" >}}) - [Release notes for 8.0.0-beta2]({{< relref "release-notes-8-0-0-beta2" >}}) - [Release notes for 8.0.0-beta1]({{< relref "release-notes-8-0-0-beta1" >}}) +- [Release notes for 7.5.15]({{< relref "release-notes-7-5-15" >}}) +- [Release notes for 7.5.13]({{< relref "release-notes-7-5-13" >}}) +- [Release notes for 7.5.12]({{< relref "release-notes-7-5-12" >}}) - [Release notes for 7.5.11]({{< relref "release-notes-7-5-11" >}}) - [Release notes for 7.5.10]({{< relref "release-notes-7-5-10" >}}) - [Release notes for 7.5.9]({{< relref "release-notes-7-5-9" >}}) diff --git a/docs/sources/release-notes/release-notes-7-5-12.md b/docs/sources/release-notes/release-notes-7-5-12.md new file mode 100644 index 0000000000000..b8c7f3fe77501 --- /dev/null +++ b/docs/sources/release-notes/release-notes-7-5-12.md @@ -0,0 +1,13 @@ ++++ +title = "Release notes for Grafana 7.5.12" +[_build] +list = false ++++ + + + +# Release notes for Grafana 7.5.12 + +### Bug fixes + +- **Security**: Fixes CVE-2021-43813 and CVE-2021-PENDING. For more information, see our [blog](https://grafana.com/blog/2021/12/10/grafana-8.3.2-and-7.5.12-released-with-moderate-severity-security-fix/) diff --git a/docs/sources/release-notes/release-notes-7-5-15.md b/docs/sources/release-notes/release-notes-7-5-15.md new file mode 100644 index 0000000000000..7a64378d95534 --- /dev/null +++ b/docs/sources/release-notes/release-notes-7-5-15.md @@ -0,0 +1,11 @@ ++++ +title = "Release notes for Grafana 7.5.15" +[_build] +list = false ++++ + +# Release notes for Grafana 7.5.15 + +- **Security**: Fixes CVE-2022-21702. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21703. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21713. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) diff --git a/docs/sources/release-notes/release-notes-8-2-5.md b/docs/sources/release-notes/release-notes-8-2-5.md index 4e47879997cad..2bc0e1f130a30 100644 --- a/docs/sources/release-notes/release-notes-8-2-5.md +++ b/docs/sources/release-notes/release-notes-8-2-5.md @@ -11,6 +11,7 @@ list = false ### Bug fixes - **Alerting:** Fix a bug where the metric in the evaluation string was not correctly populated. [#41731](https://github.com/grafana/grafana/pull/41731), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **Alerting:** Fix no data behaviour in Legacy Alerting for alert rules using the AND operator. [#41305](https://github.com/grafana/grafana/pull/41305), [@gerobinson](https://github.com/gerobinson) - **CloudMonitoring:** Ignore min and max aggregation in MQL queries. [#41302](https://github.com/grafana/grafana/pull/41302), [@sunker](https://github.com/sunker) - **Dashboards:** 'Copy' is no longer added to new dashboard titles. [#41344](https://github.com/grafana/grafana/pull/41344), [@joshhunt](https://github.com/joshhunt) - **DataProxy:** Fix overriding response body when response is a WebSocket upgrade. [#41364](https://github.com/grafana/grafana/pull/41364), [@marefr](https://github.com/marefr) @@ -20,3 +21,11 @@ list = false - **Plugins:** Ensure consistent plugin settings list response. [#41346](https://github.com/grafana/grafana/pull/41346), [@wbrowne](https://github.com/wbrowne) - **Tempo:** Fix validation of float durations. [#41400](https://github.com/grafana/grafana/pull/41400), [@ivanahuckova](https://github.com/ivanahuckova) - **Tracing:** Correct tags for each span are shown. [#41473](https://github.com/grafana/grafana/pull/41473), [@ivanahuckova](https://github.com/ivanahuckova) + +### Breaking changes + +### Fix No Data behaviour in Legacy Alerting + +In Grafana 8.2.5 and later, this change fixes a bug in the evaluation of alert rules when using the AND operator to compare two or more conditions. In Grafana 8.2.4 and earlier such alert rules would evaluate to `OK` if at least one, but not all, conditions returned no data. This change fixes that bug such that in Grafana 8.2.5 these alert rules now evaluate to `No Data`. + +If an alert should evaluate to `OK` when one or all conditions return `No Data` then this can be done via changing `If no data or all values are null` to `OK`. However, this will not preserve the old behaviour in 8.2.4 where an alert will be `OK` if at least one, but not all, conditions return no data and then `No Data` if all conditions return `No Data`. Issue [#41305](https://github.com/grafana/grafana/issues/41305) diff --git a/docs/sources/release-notes/release-notes-8-2-6.md b/docs/sources/release-notes/release-notes-8-2-6.md new file mode 100644 index 0000000000000..db4c762e02020 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-2-6.md @@ -0,0 +1,18 @@ ++++ +title = "Release notes for Grafana 8.2.6" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.2.6 + +### Features and enhancements + +- **Security:** Upgrade Docker base image to Alpine 3.14.3. [#42061](https://github.com/grafana/grafana/pull/42061), [@dsotirakis](https://github.com/dsotirakis) +- **Security:** Upgrade Go to 1.17.2. [#42427](https://github.com/grafana/grafana/pull/42427), [@idafurjes](https://github.com/idafurjes) + +### Bug fixes + +- **TimeSeries:** Fix fillBelowTo wrongly affecting fills of unrelated series. [#41998](https://github.com/grafana/grafana/pull/41998), [@leeoniya](https://github.com/leeoniya) diff --git a/docs/sources/release-notes/release-notes-8-3-0-beta1.md b/docs/sources/release-notes/release-notes-8-3-0-beta1.md new file mode 100644 index 0000000000000..7e74c6d0cf814 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-0-beta1.md @@ -0,0 +1,77 @@ ++++ +title = "Release notes for Grafana 8.3.0-beta1" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.0-beta1 + +### Features and enhancements + +- **AccessControl:** Apply fine-grained access control to licensing. (Enterprise) +- **Alerting:** Add UI for contact point testing with custom annotations and labels. [#40491](https://github.com/grafana/grafana/pull/40491), [@nathanrodman](https://github.com/nathanrodman) +- **Alerting:** Make alert state indicator in panel header work with Grafana 8 alerts. [#38713](https://github.com/grafana/grafana/pull/38713), [@domasx2](https://github.com/domasx2) +- **Alerting:** Option for Discord notifier to use webhook name. [#40463](https://github.com/grafana/grafana/pull/40463), [@Skyebold](https://github.com/Skyebold) +- **Annotations:** Deprecate AnnotationsSrv. [#39631](https://github.com/grafana/grafana/pull/39631), [@hugohaggmark](https://github.com/hugohaggmark) +- **Auditing:** Add audit logs for unified alerting endpoints. (Enterprise) +- **Auditing:** Add endpoints (plugins, datasources, library elements). (Enterprise) +- **Auth:** Omit all base64 paddings in JWT tokens for the JWT auth. [#35602](https://github.com/grafana/grafana/pull/35602), [@gillg](https://github.com/gillg) +- **Azure Monitor:** Clean up fields when editing Metrics. [#41762](https://github.com/grafana/grafana/pull/41762), [@andresmgot](https://github.com/andresmgot) +- **AzureMonitor:** Add new starter dashboards. [#39876](https://github.com/grafana/grafana/pull/39876), [@jcolladokuri](https://github.com/jcolladokuri) +- **AzureMonitor:** Add starter dashboard for app monitoring with Application Insights. [#40725](https://github.com/grafana/grafana/pull/40725), [@jcolladokuri](https://github.com/jcolladokuri) +- **Barchart/Time series:** Allow x axis label. [#41142](https://github.com/grafana/grafana/pull/41142), [@oscarkilhed](https://github.com/oscarkilhed) +- **CLI:** Improve error handling for installing plugins. [#41257](https://github.com/grafana/grafana/pull/41257), [@marefr](https://github.com/marefr) +- **CloudMonitoring:** Migrate to use backend plugin SDK contracts. [#38650](https://github.com/grafana/grafana/pull/38650), [@idafurjes](https://github.com/idafurjes) +- **CloudWatch Logs:** Add retry strategy for hitting max concurrent queries. [#39290](https://github.com/grafana/grafana/pull/39290), [@aocenas](https://github.com/aocenas) +- **CloudWatch:** Add AWS RoboMaker metrics and dimension. [#41450](https://github.com/grafana/grafana/pull/41450), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch:** Add AWS Transfer metrics and dimension. [#41168](https://github.com/grafana/grafana/pull/41168), [@ilyastoli](https://github.com/ilyastoli) +- **Dashboard:** replace datasource name with a reference object. [#33817](https://github.com/grafana/grafana/pull/33817), [@ryantxu](https://github.com/ryantxu) +- **Dashboards:** Show logs on time series when hovering. [#40110](https://github.com/grafana/grafana/pull/40110), [@ryantxu](https://github.com/ryantxu) +- **Elasticsearch:** Add support for Elasticsearch 8.0 (Beta). [#41729](https://github.com/grafana/grafana/pull/41729), [@Elfo404](https://github.com/Elfo404) +- **Elasticsearch:** Add time zone setting to Date Histogram aggregation. [#40882](https://github.com/grafana/grafana/pull/40882), [@Elfo404](https://github.com/Elfo404) +- **Elasticsearch:** Enable full range log volume histogram. [#41202](https://github.com/grafana/grafana/pull/41202), [@ifrost](https://github.com/ifrost) +- **Elasticsearch:** Full range logs volume. [#40700](https://github.com/grafana/grafana/pull/40700), [@ifrost](https://github.com/ifrost) +- **Explore:** Allow changing the graph type. [#40522](https://github.com/grafana/grafana/pull/40522), [@gabor](https://github.com/gabor) +- **Explore:** Show ANSI colors when highlighting matched words in the logs panel. [#40971](https://github.com/grafana/grafana/pull/40971), [@oliverfrye](https://github.com/oliverfrye) +- **Graph(old) panel:** Listen to events from Time series panel. [#41033](https://github.com/grafana/grafana/pull/41033), [@zoltanbedi](https://github.com/zoltanbedi) +- **Import:** Load gcom dashboards from URL. [#41799](https://github.com/grafana/grafana/pull/41799), [@ashharrison90](https://github.com/ashharrison90) +- **LibraryPanels:** Improves export and import of library panels between orgs. [#39214](https://github.com/grafana/grafana/pull/39214), [@hugohaggmark](https://github.com/hugohaggmark) +- **OAuth:** Support PKCE. [#39948](https://github.com/grafana/grafana/pull/39948), [@sakjur](https://github.com/sakjur) +- **Panel edit:** Overrides now highlight correctly when searching. [#41684](https://github.com/grafana/grafana/pull/41684), [@ashharrison90](https://github.com/ashharrison90) +- **PanelEdit:** Display drag indicators on draggable sections. [#41711](https://github.com/grafana/grafana/pull/41711), [@ashharrison90](https://github.com/ashharrison90) +- **Plugins:** Refactor Plugin Management. [#40477](https://github.com/grafana/grafana/pull/40477), [@wbrowne](https://github.com/wbrowne) +- **Prometheus:** Add custom query parameters when creating PromLink url. [#41213](https://github.com/grafana/grafana/pull/41213), [@Ian-Yy](https://github.com/Ian-Yy) +- **Prometheus:** Remove limits on metrics, labels, and values in Metrics Browser. [#40660](https://github.com/grafana/grafana/pull/40660), [@autoric](https://github.com/autoric) +- **StateTimeline:** Share cursor with rest of the panels. [#41038](https://github.com/grafana/grafana/pull/41038), [@zoltanbedi](https://github.com/zoltanbedi) +- **Tempo:** Add error details when json upload fails. [#41803](https://github.com/grafana/grafana/pull/41803), [@aocenas](https://github.com/aocenas) +- **Tempo:** Add filtering for service graph query. [#41162](https://github.com/grafana/grafana/pull/41162), [@aocenas](https://github.com/aocenas) +- **Tempo:** Add links to nodes in Service Graph pointing to Prometheus metrics. [#41135](https://github.com/grafana/grafana/pull/41135), [@aocenas](https://github.com/aocenas) +- **Time series/Bar chart panel:** Add ability to sort series via legend. [#40226](https://github.com/grafana/grafana/pull/40226), [@zoltanbedi](https://github.com/zoltanbedi) +- **TimeSeries:** Allow multiple axes for the same unit. [#41635](https://github.com/grafana/grafana/pull/41635), [@dprokop](https://github.com/dprokop) +- **TraceView:** Allow span links defined on dataFrame. [#40563](https://github.com/grafana/grafana/pull/40563), [@aocenas](https://github.com/aocenas) +- **Transformations:** Support a rows mode in labels to fields. [#41020](https://github.com/grafana/grafana/pull/41020), [@ryantxu](https://github.com/ryantxu) +- **ValueMappings:** Don't apply field config defaults to time fields. [#41132](https://github.com/grafana/grafana/pull/41132), [@torkelo](https://github.com/torkelo) +- **Variables:** Only update panels that are impacted by variable change. [#39420](https://github.com/grafana/grafana/pull/39420), [@hugohaggmark](https://github.com/hugohaggmark) + +### Bug fixes + +- **API:** Fix dashboard quota limit for imports. [#41495](https://github.com/grafana/grafana/pull/41495), [@yangkb09](https://github.com/yangkb09) +- **Alerting:** Fix rule editor issues with Azure Monitor data source. [#41317](https://github.com/grafana/grafana/pull/41317), [@domasx2](https://github.com/domasx2) +- **Azure monitor:** Make sure alert rule editor is not enabled when template variables are being used. [#41335](https://github.com/grafana/grafana/pull/41335), [@sunker](https://github.com/sunker) +- **CloudMonitoring:** Fix annotation queries. [#41529](https://github.com/grafana/grafana/pull/41529), [@sunker](https://github.com/sunker) +- **CodeEditor:** Trigger the latest getSuggestions() passed to CodeEditor. [#40544](https://github.com/grafana/grafana/pull/40544), [@DukeManh](https://github.com/DukeManh) +- **Dashboard:** Remove the current panel from the list of options in the Dashboard datasource. [#41826](https://github.com/grafana/grafana/pull/41826), [@ashharrison90](https://github.com/ashharrison90) +- **Encryption:** Fix decrypting secrets in alerting migration. [#41061](https://github.com/grafana/grafana/pull/41061), [@undef1nd](https://github.com/undef1nd) +- **InfluxDB:** Fix corner case where index is too large in ALIAS field. [#41562](https://github.com/grafana/grafana/pull/41562), [@gabor](https://github.com/gabor) +- **NavBar:** Order App plugins alphabetically. [#40078](https://github.com/grafana/grafana/pull/40078), [@ashharrison90](https://github.com/ashharrison90) +- **NodeGraph:** Fix zooming sensitivity on touchpads. [#40718](https://github.com/grafana/grafana/pull/40718), [@aocenas](https://github.com/aocenas) +- **Plugins:** Add OAuth pass-through logic to api/ds/query endpoint. [#41352](https://github.com/grafana/grafana/pull/41352), [@wbrowne](https://github.com/wbrowne) +- **Snapshots:** Fix panel inspector for snapshot data. [#41530](https://github.com/grafana/grafana/pull/41530), [@joshhunt](https://github.com/joshhunt) +- **Tempo:** Fix basic auth password reset on adding tag. [#41808](https://github.com/grafana/grafana/pull/41808), [@aocenas](https://github.com/aocenas) +- **ValueMapping:** Fixes issue with regex mappings. [#41515](https://github.com/grafana/grafana/pull/41515), [@mcdee](https://github.com/mcdee) + +### Plugin development fixes & changes + +- **grafana/ui:** Enable slider marks display. [#41275](https://github.com/grafana/grafana/pull/41275), [@dprokop](https://github.com/dprokop) diff --git a/docs/sources/release-notes/release-notes-8-3-0-beta2.md b/docs/sources/release-notes/release-notes-8-3-0-beta2.md new file mode 100644 index 0000000000000..b9ca672a98caa --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-0-beta2.md @@ -0,0 +1,80 @@ ++++ +title = "Release notes for Grafana 8.3.0-beta2" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.0-beta2 + +### Features and enhancements + +- **Alerting:** Create DatasourceError alert if evaluation returns error. [#41869](https://github.com/grafana/grafana/pull/41869), [@gerobinson](https://github.com/gerobinson) +- **Alerting:** Make Unified Alerting enabled by default for those who do not use legacy alerting. [#42200](https://github.com/grafana/grafana/pull/42200), [@armandgrillet](https://github.com/armandgrillet) +- **Alerting:** Support mute timings configuration through the api for the embedded alert manager. [#41533](https://github.com/grafana/grafana/pull/41533), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **CloudWatch:** Add missing AWS/Events metrics. [#42164](https://github.com/grafana/grafana/pull/42164), [@n2N8Z](https://github.com/n2N8Z) +- **Docs:** Add easier to find deprecation notices to certain data sources and to the changelog. [#41938](https://github.com/grafana/grafana/pull/41938), [@gabor](https://github.com/gabor) +- **Plugins Catalog:** Enable install controls based on the pluginAdminEnabled flag. [#41686](https://github.com/grafana/grafana/pull/41686), [@leventebalogh](https://github.com/leventebalogh) +- **Query caching:** Increase max_value_mb default to 10. (Enterprise) +- **Table:** Add space between values for the DefaultCell. [#42246](https://github.com/grafana/grafana/pull/42246), [@kirederik](https://github.com/kirederik) +- **Table:** Add space between values on JSONViewCell. [#42156](https://github.com/grafana/grafana/pull/42156), [@kirederik](https://github.com/kirederik) +- **Tracing:** Make query editors available in dashboard for Tempo and Zipkin. [#41974](https://github.com/grafana/grafana/pull/41974), [@ivanahuckova](https://github.com/ivanahuckova) + +### Bug fixes + +- **AccessControl:** Renamed `orgs` roles, removed `fixed:orgs:reader` introduced in beta1. [#42049](https://github.com/grafana/grafana/pull/42049), [@gamab](https://github.com/gamab) +- **Azure Monitor:** Add trap focus for modals in grafana/ui and other small a11y fixes for Azure Monitor. [#41449](https://github.com/grafana/grafana/pull/41449), [@sarahzinger](https://github.com/sarahzinger) +- **CodeEditor:** Prevent suggestions from being clipped. [#42120](https://github.com/grafana/grafana/pull/42120), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard:** Fix cache timeout persistence. [#42204](https://github.com/grafana/grafana/pull/42204), [@hugohaggmark](https://github.com/hugohaggmark) +- **Datasource:** Fix stable sort order of query responses. [#41868](https://github.com/grafana/grafana/pull/41868), [@marefr](https://github.com/marefr) +- **Explore:** Fix error in query history when removing last item. [#42179](https://github.com/grafana/grafana/pull/42179), [@gabor](https://github.com/gabor) +- **Logs:** Fix requesting of older logs when flipped order. [#41966](https://github.com/grafana/grafana/pull/41966), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Fix running of health check query based on access mode. [#42189](https://github.com/grafana/grafana/pull/42189), [@ivanahuckova](https://github.com/ivanahuckova) +- **TextPanel:** Fix suggestions for existing panels. [#42195](https://github.com/grafana/grafana/pull/42195), [@hugohaggmark](https://github.com/hugohaggmark) +- **Tracing:** Fix incorrect indentations due to reoccurring spanIDs. [#41919](https://github.com/grafana/grafana/pull/41919), [@ivanahuckova](https://github.com/ivanahuckova) +- **Tracing:** Show start time of trace with milliseconds precision. [#42132](https://github.com/grafana/grafana/pull/42132), [@ivanahuckova](https://github.com/ivanahuckova) +- **Variables:** Make renamed or missing variable section expandable. [#41964](https://github.com/grafana/grafana/pull/41964), [@hugohaggmark](https://github.com/hugohaggmark) + +### Breaking changes + +### Grafana 8 Alerting enabled by default for installations that do not use legacy alerting + +Starting with Grafana v8.3.0, if you have **not** explicitly disabled unified alerting and **do not** have legacy alerts set up you are automatically "migrated" to Grafana 8 Alerting. + +A migration **from legacy to Grafana 8 Alerting** will never incur a data loss, as the previous data is kept around for rollback purposes. However, going from **Grafana 8 Alerting to legacy alerting** will delete all the data created for Grafana 8 Alerting. It is recommended that you **backup your database** before attempting a migration between systems. + +If unclear, please verify the table below: + +| `[alerting][enabled]` | `[unified_alerting][enabled]` | With Existing Legacy Alerts | Result | +| --------------------- | ----------------------------- | --------------------------- | ------------------ | +| `true` | `true` | N/A | Error | +| `true` | `false` | N/A | Legacy Alerting | +| `true` | not set | Yes | Legacy Alerting | +| `true` | not set | No | Grafana 8 Alerting | +| not set | `true` | N/A | Grafana 8 Alerting | +| not set | `false` | N/A | Legacy Alerting | +| not set | not set | Yes | Legacy Alerting | +| not set | not set | No | Grafana 8 Alerting | +| `false` | `true` | N/A | Grafana 8 Alerting | +| `false` | `false` | N/A | Alerting disabled | +| `false` | not set | N/A | Grafana 8 Alerting | + +N/A in the "With Existing Legacy Alerts" column means that it does not matter if you have legacy alerts or not. +Issue [#42200](https://github.com/grafana/grafana/issues/42200) + +### Keep Last State for "If execution error or timeout" when upgrading to Grafana 8 alerting + +In Grafana 8.3.0-beta2 we changed how alert rules that use `Keep Last State` for `If execution error or timeout` are upgraded from Legacy Alerting to Grafana 8 alerting. In 8.3.0-beta1 and earlier, alert rules with `Keep Last State` for `If execution error or timeout` were changed to `Alerting` when upgrading from Legacy Alerting to Grafana 8 alerting. However, in 8.3.0-beta2 these alert rules are now upgraded to a new option called `Error`. With this option, on encountering an error evaluating an alert rule, Grafana creates a special alert called `DatasourceError` with the `rule_uid` and `ref_id` as labels and an annotation called `Error` with the error message. Issue [#41869](https://github.com/grafana/grafana/issues/41869) + +### Deprecations + +The access mode "browser" is deprecated in the following data sources and will be removed in a later release: + +- Prometheus +- InfluxDB +- Elasticsearch Issue [#41938](https://github.com/grafana/grafana/issues/41938) + +### Plugin development fixes & changes + +- **Select:** Select menus now properly scroll during keyboard navigation. [#41917](https://github.com/grafana/grafana/pull/41917), [@ashharrison90](https://github.com/ashharrison90) diff --git a/docs/sources/release-notes/release-notes-8-3-0.md b/docs/sources/release-notes/release-notes-8-3-0.md new file mode 100644 index 0000000000000..de72f4fafa32a --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-0.md @@ -0,0 +1,28 @@ ++++ +title = "Release notes for Grafana 8.3.0" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.0 + +### Features and enhancements + +- **Alerting:** Prevent folders from being deleted when they contain alerts. [#42307](https://github.com/grafana/grafana/pull/42307), [@peterholmberg](https://github.com/peterholmberg) +- **Alerting:** Show full preview value in tooltip. [#42445](https://github.com/grafana/grafana/pull/42445), [@peterholmberg](https://github.com/peterholmberg) +- **BarGauge:** Limit title width when name is really long. [#42346](https://github.com/grafana/grafana/pull/42346), [@torkelo](https://github.com/torkelo) +- **CloudMonitoring:** Avoid to escape regexps in filters. [#41961](https://github.com/grafana/grafana/pull/41961), [@andresmgot](https://github.com/andresmgot) +- **CloudWatch:** Add support for AWS Metric Insights. [#42487](https://github.com/grafana/grafana/pull/42487), [@sunker](https://github.com/sunker) +- **TooltipPlugin:** Remove other panels' shared tooltip in edit panel. [#42187](https://github.com/grafana/grafana/pull/42187), [@mdvictor](https://github.com/mdvictor) +- **Visualizations:** Limit y label width to 40% of visualization width. [#42350](https://github.com/grafana/grafana/pull/42350), [@torkelo](https://github.com/torkelo) + +### Bug fixes + +- **Alerting:** Clear alerting rule evaluation errors after intermittent failures. [#42386](https://github.com/grafana/grafana/pull/42386), [@gotjosh](https://github.com/gotjosh) +- **Alerting:** Fix refresh on legacy Alert List panel. [#42322](https://github.com/grafana/grafana/pull/42322), [@peterholmberg](https://github.com/peterholmberg) +- **Dashboard:** Fix queries for panels with non-integer widths. [#42420](https://github.com/grafana/grafana/pull/42420), [@gabor](https://github.com/gabor) +- **Explore:** Fix url update inconsistency. [#42288](https://github.com/grafana/grafana/pull/42288), [@gabor](https://github.com/gabor) +- **Prometheus:** Fix range variables interpolation for time ranges smaller than 1 second. [#42242](https://github.com/grafana/grafana/pull/42242), [@ivanahuckova](https://github.com/ivanahuckova) +- **ValueMappings:** Fixes issue with regex value mapping that only sets color. [#42311](https://github.com/grafana/grafana/pull/42311), [@torkelo](https://github.com/torkelo) diff --git a/docs/sources/release-notes/release-notes-8-3-1.md b/docs/sources/release-notes/release-notes-8-3-1.md new file mode 100644 index 0000000000000..9dd1e259082da --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-1.md @@ -0,0 +1,13 @@ ++++ +title = "Release notes for Grafana 8.3.1" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.1 + +### Bug fixes + +- **Security**: Fixes CVE-2021-43798. For more information, see our [blog](https://grafana.com/blog/2021/12/07/grafana-8.3.1-8.2.7-8.1.8-and-8.0.7-released-with-high-severity-security-fix/) diff --git a/docs/sources/release-notes/release-notes-8-3-2.md b/docs/sources/release-notes/release-notes-8-3-2.md new file mode 100644 index 0000000000000..e6937deec6b63 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-2.md @@ -0,0 +1,13 @@ ++++ +title = "Release notes for Grafana 8.3.2" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.2 + +### Bug fixes + +- **Security**: Fixes CVE-2021-43813 and CVE-2021-PENDING. For more information, see our [blog](https://grafana.com/blog/2021/12/10/grafana-8.3.2-and-7.5.12-released-with-moderate-severity-security-fix/) diff --git a/docs/sources/release-notes/release-notes-8-3-3.md b/docs/sources/release-notes/release-notes-8-3-3.md new file mode 100644 index 0000000000000..f8e9ff36cefde --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-3.md @@ -0,0 +1,31 @@ ++++ +title = "Release notes for Grafana 8.3.3" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.3 + +### Features and enhancements + +- **BarChart:** Use new data error view component to show actions in panel edit. [#42474](https://github.com/grafana/grafana/pull/42474), [@torkelo](https://github.com/torkelo) +- **CloudMonitor:** Iterate over pageToken for resources. [#42546](https://github.com/grafana/grafana/pull/42546), [@iwysiu](https://github.com/iwysiu) +- **Macaron:** Prevent WriteHeader invalid HTTP status code panic. [#42973](https://github.com/grafana/grafana/pull/42973), [@bergquist](https://github.com/bergquist) + +### Bug fixes + +- **AnnoListPanel:** Fix interpolation of variables in tags. [#42318](https://github.com/grafana/grafana/pull/42318), [@francoisdtm](https://github.com/francoisdtm) +- **CloudWatch:** Allow queries to have no dimensions specified. [#42800](https://github.com/grafana/grafana/pull/42800), [@sunker](https://github.com/sunker) +- **CloudWatch:** Fix broken queries for users migrating from 8.2.4/8.2.5 to 8.3.0. [#42611](https://github.com/grafana/grafana/pull/42611), [@sunker](https://github.com/sunker) +- **CloudWatch:** Make sure MatchExact flag gets the right value. [#42621](https://github.com/grafana/grafana/pull/42621), [@sunker](https://github.com/sunker) +- **Dashboards:** Fix so that empty folders can be deleted from the manage dashboards/folders page. [#42527](https://github.com/grafana/grafana/pull/42527), [@ashharrison90](https://github.com/ashharrison90) +- **InfluxDB:** Improve handling of metadata query errors in InfluxQL. [#42500](https://github.com/grafana/grafana/pull/42500), [@gabor](https://github.com/gabor) +- **Loki:** Fix adding of ad hoc filters for queries with parser and line_format expressions. [#42590](https://github.com/grafana/grafana/pull/42590), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Fix running of exemplar queries for non-histogram metrics. [#42749](https://github.com/grafana/grafana/pull/42749), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Interpolate template variables in interval. [#42637](https://github.com/grafana/grafana/pull/42637), [@ivanahuckova](https://github.com/ivanahuckova) +- **StateTimeline:** Fix toolitp not showing when for frames with multiple fields. [#42741](https://github.com/grafana/grafana/pull/42741), [@dprokop](https://github.com/dprokop) +- **TraceView:** Fix virtualized scrolling when trace view is opened in right pane in Explore. [#42480](https://github.com/grafana/grafana/pull/42480), [@autoric](https://github.com/autoric) +- **Variables:** Fix repeating panels for on time range changed variables. [#42828](https://github.com/grafana/grafana/pull/42828), [@hugohaggmark](https://github.com/hugohaggmark) +- **Variables:** Fix so queryparam option works for scoped variables. [#42742](https://github.com/grafana/grafana/pull/42742), [@hugohaggmark](https://github.com/hugohaggmark) diff --git a/docs/sources/release-notes/release-notes-8-3-4.md b/docs/sources/release-notes/release-notes-8-3-4.md new file mode 100644 index 0000000000000..f4bb71d04d457 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-4.md @@ -0,0 +1,42 @@ ++++ +title = "Release notes for Grafana 8.3.4" +[_build] +list = false ++++ + + + +# Release notes for Grafana 8.3.4 + +### Features and enhancements + +- **Alerting:** Allow configuration of non-ready alertmanagers. [#43063](https://github.com/grafana/grafana/pull/43063), [@alexweav](https://github.com/alexweav) +- **Alerting:** Allow customization of Google chat message. [#43568](https://github.com/grafana/grafana/pull/43568), [@alexweav](https://github.com/alexweav) +- **Alerting:** Allow customization of Google chat message (#43568). [#43723](https://github.com/grafana/grafana/pull/43723), [@alexweav](https://github.com/alexweav) +- **AppPlugins:** Support app plugins with only default nav. [#43016](https://github.com/grafana/grafana/pull/43016), [@torkelo](https://github.com/torkelo) +- **InfluxDB:** InfluxQL: query editor: skip fields in metadata queries. [#42543](https://github.com/grafana/grafana/pull/42543), [@gabor](https://github.com/gabor) +- **Postgres/MySQL/MSSQL:** Cancel in-flight SQL query if user cancels query in grafana. [#43890](https://github.com/grafana/grafana/pull/43890), [@mdvictor](https://github.com/mdvictor) +- **Prometheus:** Forward oauth tokens after prometheus datasource migration. [#43686](https://github.com/grafana/grafana/pull/43686), [@MasslessParticle](https://github.com/MasslessParticle) + +### Bug fixes + +- **Azure Monitor:** Bug fix for variable interpolations in metrics dropdowns. [#43251](https://github.com/grafana/grafana/pull/43251), [@sarahzinger](https://github.com/sarahzinger) +- **Azure Monitor:** Improved error messages for variable queries. [#43213](https://github.com/grafana/grafana/pull/43213), [@sunker](https://github.com/sunker) +- **CloudMonitoring:** Fixes broken variable queries that use group bys. [#43914](https://github.com/grafana/grafana/pull/43914), [@sunker](https://github.com/sunker) +- **Configuration:** You can now see your expired API keys if you have no active ones. [#42452](https://github.com/grafana/grafana/pull/42452), [@ashharrison90](https://github.com/ashharrison90) +- **Elasticsearch:** Fix handling multiple datalinks for a single field. [#44029](https://github.com/grafana/grafana/pull/44029), [@Elfo404](https://github.com/Elfo404) +- **Export:** Fix error being thrown when exporting dashboards using query variables that reference the default datasource. [#44034](https://github.com/grafana/grafana/pull/44034), [@ashharrison90](https://github.com/ashharrison90) +- **ImportDashboard:** Fixes issue with importing dashboard and name ending up in uid. [#43451](https://github.com/grafana/grafana/pull/43451), [@torkelo](https://github.com/torkelo) +- **Login:** Page no longer overflows on mobile. [#43739](https://github.com/grafana/grafana/pull/43739), [@ashharrison90](https://github.com/ashharrison90) +- **Plugins:** Set backend metadata property for core plugins. [#43349](https://github.com/grafana/grafana/pull/43349), [@marefr](https://github.com/marefr) +- **Prometheus:** Fill missing steps with null values. [#43622](https://github.com/grafana/grafana/pull/43622), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Fix interpolation of $\_\_rate_interval variable. [#44035](https://github.com/grafana/grafana/pull/44035), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Interpolate variables with curly brackets syntax. [#42927](https://github.com/grafana/grafana/pull/42927), [@ivanahuckova](https://github.com/ivanahuckova) +- **Prometheus:** Respect the http-method data source setting. [#42753](https://github.com/grafana/grafana/pull/42753), [@gabor](https://github.com/gabor) +- **Table:** Fixes issue with field config applied to wrong fields when hiding columns. [#43376](https://github.com/grafana/grafana/pull/43376), [@torkelo](https://github.com/torkelo) +- **Toolkit:** Fix bug with rootUrls not being properly parsed when signing a private plugin. [#43014](https://github.com/grafana/grafana/pull/43014), [@dessen-xu](https://github.com/dessen-xu) +- **Variables:** Fix so data source variables are added to adhoc configuration. [#43881](https://github.com/grafana/grafana/pull/43881), [@hugohaggmark](https://github.com/hugohaggmark) + +### Plugin development fixes & changes + +- **Toolkit:** Revert build config so tslib is bundled with plugins to prevent plugins from crashing. [#43556](https://github.com/grafana/grafana/pull/43556), [@mckn](https://github.com/mckn) diff --git a/docs/sources/release-notes/release-notes-8-3-5.md b/docs/sources/release-notes/release-notes-8-3-5.md new file mode 100644 index 0000000000000..9d7cf1a0f86b7 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-5.md @@ -0,0 +1,11 @@ ++++ +title = "Release notes for Grafana 8.3.5" +[_build] +list = false ++++ + +# Release notes for Grafana 8.3.5 + +- **Security**: Fixes CVE-2022-21702. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21703. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) +- **Security**: Fixes CVE-2022-21713. For more information, see our [blog](https://grafana.com/blog/2022/02/08/grafana-7.5.15-and-8.3.5-released-with-moderate-severity-security-fixes/) diff --git a/docs/sources/release-notes/release-notes-8-3-7.md b/docs/sources/release-notes/release-notes-8-3-7.md new file mode 100644 index 0000000000000..cf47672019a1d --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-3-7.md @@ -0,0 +1,12 @@ ++++ +title = "Release notes for Grafana 8.3.7" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.3.7 + +### Bug fixes + +- **Provisioning:** Ensure that the default value for orgID is set when provisioning datasources to be deleted. [#44244](https://github.com/grafana/grafana/pull/44244), [@filewalkwithme](https://github.com/filewalkwithme) diff --git a/docs/sources/sharing/share-dashboard.md b/docs/sources/sharing/share-dashboard.md index 6a19ea5af16e8..771b55a31166d 100644 --- a/docs/sources/sharing/share-dashboard.md +++ b/docs/sources/sharing/share-dashboard.md @@ -30,14 +30,14 @@ To share a direct link: A dashboard snapshot shares an interactive dashboard publicly. Grafana strips sensitive data like queries (metric, template and annotation) and panel links, leaving only the visible metric data and series names embedded into your dashboard. Dashboard snapshots can be accessed by anyone with the link. -You can publish snapshots to your local instance or to [snapshot.raintank.io](http://snapshot.raintank.io). The latter is a free service +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that allows you to publish dashboard snapshots to an external Grafana instance. The same rules still apply: anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. ![Dashboard share snapshot](/static/img/docs/sharing/share-dashboard-snapshot-7-3.png) To publish a snapshot: -1. Click on **Local Snapshot** or **Publish to snapshot.raintank.io**. This generates the link of the snapshot. +1. Click on **Local Snapshot** or **Publish to snapshots.raintank.io**. This generates the link of the snapshot. 1. Copy the snapshot link, and share it either within your organization or publicly on the web. In case you created a snapshot by mistake, click **delete snapshot** to remove the snapshot from your Grafana instance. diff --git a/docs/sources/sharing/share-panel.md b/docs/sources/sharing/share-panel.md index 05b3cc10cde46..be16c994971a3 100644 --- a/docs/sources/sharing/share-panel.md +++ b/docs/sources/sharing/share-panel.md @@ -46,14 +46,14 @@ https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=156871968017 A panel snapshot shares an interactive panel publicly. Grafana strips sensitive data leaving only the visible metric data and series names embedded into your dashboard. Panel snapshots can be accessed by anyone with the link. -You can publish snapshots to your local instance or to [snapshot.raintank.io](http://snapshot.raintank.io). The latter is a free service provided by [Raintank](http://raintank.io), that allows you to publish dashboard snapshots to an external Grafana instance. You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by [Grafana Labs](https://grafana.com), that allows you to publish dashboard snapshots to an external Grafana instance. You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. ![Panel share snapshot](/static/img/docs/sharing/share-panel-snapshot-8-0.png) To publish a snapshot: 1. In the Share Panel dialog, click **Snapshot** to open the tab. -1. Click on **Local Snapshot** or **Publish to snapshot.raintank.io**. This generates the link of the snapshot. +1. Click on **Local Snapshot** or **Publish to snapshots.raintank.io**. This generates the link of the snapshot. 1. Copy the snapshot link, and share it either within your organization or publicly on the web. If you created a snapshot by mistake, click **delete snapshot** to remove the snapshot from your Grafana instance. @@ -70,7 +70,7 @@ Here is an example of the HTML code: ```html + ## Library panel diff --git a/docs/sources/variables/variable-types/global-variables.md b/docs/sources/variables/variable-types/global-variables.md index c7656ce30095a..ab02f3ae6d63d 100644 --- a/docs/sources/variables/variable-types/global-variables.md +++ b/docs/sources/variables/variable-types/global-variables.md @@ -15,11 +15,11 @@ Grafana has global built-in variables that can be used in expressions in the que This variable is the name of the current dashboard. -## $**from and $**to +## $\_\_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+ +> This special formatting syntax is only available in Grafana 7.1.2+ | Syntax | Example result | Description | | ------------------------ | ------------------------ | --------------------------------------------------------------------------------------------------------- | diff --git a/docs/sources/visualizations/_index.md b/docs/sources/visualizations/_index.md index baa08e3e75a6e..d9b8bc0d8d0f3 100644 --- a/docs/sources/visualizations/_index.md +++ b/docs/sources/visualizations/_index.md @@ -8,14 +8,17 @@ aliases = ["/docs/grafana/latest/panels/visualizations/"] Grafana offers a variety of visualizations to support different use cases. This section of the documentation highlights the built-in panels, their options and typical usage. +> **Note:** If you are unsure which visualization to pick, Grafana can provide visualization suggestions based on the panel query. When you select a visualization, Grafana will show a preview with that visualization applied. For more information, see the [add a panel]({{< relref "../panels/add-a-panel.md" >}}) documentation. + - Graphs & charts - [Time series]({{< relref "./time-series/_index.md" >}}) is the default and main Graph visualization. - [State timeline]({{< relref "./state-timeline.md" >}}) for state changes over time. - [Status history]({{< relref "./status-history.md" >}}) for periodic state over time. - [Bar chart]({{< relref "./bar-chart.md" >}}) shows any categorical data. - [Histogram]({{< relref "./histogram.md" >}}) calculates and shows value distribution in a bar chart. - - [Heatmap]({{< relref "./heatmap.md" >}}). - - [Pie chart]({{< relref "./pie-chart-panel.md" >}}). + - [Heatmap]({{< relref "./heatmap.md" >}}) visualizes data in two dimensions, used typically for the magnitude of a phenomenon. + - [Pie chart]({{< relref "./pie-chart-panel.md" >}}) is typically used where proportionality is important. + - [Candlestick]({{< relref "./candlestick.md" >}}) is typically for financial data where the focus is price/data movement. - Stats & numbers - [Stat]({{< relref "./stat-panel.md" >}}) for big stats and optional sparkline. - [Gauge]({{< relref "./gauge-panel.md" >}}) is a normal radial gauge. @@ -27,7 +30,7 @@ Grafana offers a variety of visualizations to support different use cases. This - Widgets - [Dashboard list]({{< relref "./dashboard-list-panel.md" >}}) can list dashboards. - [Alert list]({{< relref "./alert-list-panel.md" >}}) can list alerts. - - [Text panel]({{< relref "./alert-list-panel.md" >}}) can show markdown and html. + - [Text panel]({{< relref "./text-panel.md" >}}) can show markdown and html. - [News panel]({{< relref "./news-panel.md" >}}) can show RSS feeds. ## Get more diff --git a/docs/sources/visualizations/candlestick.md b/docs/sources/visualizations/candlestick.md new file mode 100644 index 0000000000000..632dfb13409ac --- /dev/null +++ b/docs/sources/visualizations/candlestick.md @@ -0,0 +1,51 @@ ++++ +title = "Candlestick" +description = "Candlestick visualization documentation" +keywords = ["grafana", "Candlestick", "OHLC", "panel", "documentation"] +aliases = ["/docs/grafana/latest/features/panels/candlestick/", "/docs/grafana/latest/panels/visualizations/candlestick/"] +weight = 600 ++++ + +# Candlestick + +The Candlestick panel allows you to visualize data that includes a number of consistent dimensions focused on price movement. The Candlestick panel includes an Open-High-Low-Close (OHLC) mode, as well as support for additional dimensions based on time series data. + +{{< figure src="/static/img/docs/candlestick-panel/candlestick-panel-8-3.png" max-width="1200px" caption="Candlestick panel" >}} + +The Candlestick panel builds upon the foundation of the [time series]({{< relref "./time-series/_index.md" >}}) panel and includes many common configuration settings. + +## Mode + +The mode options allow you to toggle which dimensions are used for the visualization. + +- **Candles** limits the panel dimensions to the open, high, low, and close dimensions used by candlestick visualizations. +- **Volume** limits the panel dimension to the volume dimension. +- **Both** is the default behavior for the candlestick panel. It includes both candlestick and volume visualizations. + +## Candle style + +- **Candles** is the default display style and creates candle-style visualizations between the open and close dimensions. +- **OHLC Bars** displays the four core dimensions open, high, low, and close values. + +## Color strategy + +- **Since Open** is the default behavior. This mode will utilize the _Up_ color (below) if the intra-period price movement is positive. In other words, if the value on close is greater or equal to the value on open, the _Up_ color is used. +- **Since Prior Close** is an alternative display method based where the color of the candle is based on the inter-period price movement or change in value. In other words, if the value on open is greater than the previous value on close, the _Up_ color is used. If the value on open is lower than the previous value on close, the _Down_ color is used. _This option also triggers the hollow candlestick visualization mode_. Hollow candlesticks indicate that the intra-period movement is positive (value is higher on close than on open), filled candlesticks indicate the intra-period change is negative (value is lower on close than on open). To learn more, see the [explanation of the differences](https://thetradingbible.com/how-to-read-hollow-candlesticks). + +## Up & Down Colors + +The **Up color** and **Down color** options select which colors are used when the price movement is up or down. Please note that the _Color strategy_ above will determine if intra-period or inter-period price movement is used to select the candle or OHLC bar color. + +## Open, High, Low, Close + +The candlestick panel will attempt to map fields to the appropriate dimension. The **Open**, **High**, **Low**, and **Close** options allow you to map your data to these dimensions if the panel is unable to do so. + +- **Open** corresponds to the starting value of the given period. +- **High** corresponds to the highest value of the given period. +- **Low** corresponds to the lowest value of the given period. +- **Close** corresponds to the final (end) value of the given period. +- **Volume** corresponds to the sample count in the given period. (e.g. number of trades) + +## Additional fields + +The candlestick panel is based on the time series panel. It can visualization additional data dimensions beyond open, high, low, close, and volume The **Include** and **Ignore** options allow the panel to visualize other included data such as simple moving averages, Bollinger bands and more, using the same styles and configurations available in the [time series]({{< relref "./time-series/_index.md" >}}) panel. diff --git a/docs/sources/visualizations/geomap.md b/docs/sources/visualizations/geomap.md index 22a4dafffa58b..26f4cee798fdc 100644 --- a/docs/sources/visualizations/geomap.md +++ b/docs/sources/visualizations/geomap.md @@ -12,6 +12,100 @@ The Geomap panel visualization allows you to view and customize the world map us {{< figure src="/static/img/docs/geomap-panel/geomap-example-8-1-0.png" max-width="1200px" caption="Geomap panel" >}} +## Map View + +The map view controls the initial view of the map when the dashboard loads. + +### Initial View + +The initial view configures how the GeoMap panel renders when the panel is first loaded. + +- **View** sets the center for the map when the panel first loads. +- **Latitude** (available when the **View** mode is _Coordinates_) +- **Longitude** (available when the **View** mode is _Coordinates_) +- **Zoom** sets the initial zoom level for the GeoMap panel. + +## Data layer + +The Geomap visualization supports multiple Data Layers. Each data layer determines how you visualize geospatial data on top of the base map. + +### Layer Types + +There are four-layer types to choose from in the Geomap visualization. + +- **Marker** renders a marker at each data point. +- **Heatmap** visualizes a heatmap of the data. +- **GeoJSON** renders static data from a geojson file. + +### Layer Controls + +The layer controls allow you to create layers, change their name, reorder and delete layers. + +- **Add layer** creates an additional, configurable data layer for the Geomap visualization. When you add a layer, you are prompted to select a layer type. You can change the layer type at any point during panel configuration. See the **Layer Types** section above for details on each layer type. +- The layer controls allow you to rename, delete, and reorder the layers of the panel. + - **Edit layer name (pencil icon)** renames the layer. + - **Trash Bin** deletes the layer. + - **Reorder (six dots/grab handle)** allows you to change the layer order. Data on higher layers will appear above data on lower layers. The panel will update the layer order as you drag and drop to help simplify choosing a layer order. + +You can add multiple layers of data to a single Geomap panel in order to create rich, detailed visualizations. + +### Location + +The Geomap panel needs a source of geographical data. This data comes from a database query, and there are four mapping options for your data. + +- **Auto** automatically searches for location data. Use this option when your query is based on one of the following names for data fields. + - geohash: “geohash” + - latitude: “latitude”, “lat” + - longitude: “longitude”, “lng”, “lon” + - lookup: “lookup” +- **Coords** specifies that your query holds coordinate data. You will get prompted to select numeric data fields for latitude and longitude from your database query. +- **Geohash** specifies that your query holds geohash data. You will get prompted to select a string data field for the geohash from your database query. +- **Lookup** specifies that your query holds location name data that needs to be mapped to a value. You will get prompted to select the lookup field from your database query and a gazetteer. The gazetteer is the directory that is used to map your queried data to a geographical point. + +### Markers layer + +The markers layer allows you to display data points as different marker shapes such as circles, squares, triangles, stars, and more. + +![Markers Layer](/static/img/docs/geomap-panel/geomap-markers-8-1-0.png) + +![Markers Layer Options](/static/img/docs/geomap-panel/geomap-markers-options-8-1-0.png) + +- **Marker Color** configures the color of the marker. The default `Single color` keeps all points a single color. There is an alternate option to have multiple colors depending on the data point values and the threshold set at the `Thresholds` section. +- **Marker Size** configures the size of the marker. Default is `Fixed size`, making all marker size the same regardless of the data points. However, there is also an option to scale the circles to the corresponding data points. `Min` and `Max` marker size has to be set such that the Marker layer can scale within this range. +- **Marker Shape** allows you to choose the shape, icon, or graphic to aid in providing additional visual context to your data. Choose from assets that are included with Grafana such as simple shapes or the Unicon library. You can also specify a URL containing an image asset. The image must be a scalable vector graphic (SVG). +- **Fill opacity** configures the transparency of each marker. + +### Heatmap layer + +The heatmap layer clusters various data points to visualize locations with different densities. +To add a heatmap layer: + +Click on the drop-down menu under Data Layer and choose `Heatmap`. + +Similar to `Markers`, you are prompted with various options to determine which data points to visualize and how. + +![Heatmap Layer](/static/img/docs/geomap-panel/geomap-heatmap-8-1-0.png) + +![Heatmap Layer Options](/static/img/docs/geomap-panel/geomap-heatmap-options-8-1-0.png) + +- **Weight values** configure the intensity of the heatmap clusters. `Fixed value` keeps a constant weight value throughout all data points. This value should be in the range of 0~1. Similar to Markers, there is an alternate option in the drop-down to automatically scale the weight values depending on data values. +- **Radius** configures the size of the heatmap clusters. +- **Blur** configures the amount of blur on each cluster. + +### GeoJSON layer + +The GeoJSON layer allows you to select and load a static GeoJSON file from the filesystem. + +- **GeoJSON URL** provides a choice of GeoJSON files that ship with Grafana. +- **Default Style** controls which styles to apply when no rules above match. + - **Color** configures the color of the default style + - **Opacity** configures the default opacity +- **Style Rules** apply styles based on feature properties + - **Rule** allows you to select a _feature_, _condition_, and _value_ from the GeoJSON file in order to define a rule. The trash bin icon can be used to delete the current rule. + - **Color** configures the color of the style for the current rule + - **Opacity** configures the transparency level for the current rule +- **Add style rule** creates additional style rules. + ## Base layer The base layer loads in a blank world map from the tile server to the Grafana panel. Several base layer options are available each with specific configuration options to style the base map. The default base layer is CartoDB base map. Custom default base layers can be defined in the `.ini` configuration file. @@ -80,56 +174,3 @@ default_baselayer_config = `{ ``` `enable_custom_baselayers` allows you to enable or disable custom open source base maps that are already implemented. The default is `true`. - -## Data layer - -The data layer in the Geomap plugin determines how you visualize geospatial data on top of the base map. - -### Location - -The Geomap panel needs a source of geographical data. This data comes from a database query, and there are four mapping options for your data. - -- **Auto** automatically searches for location data. Use this option when your query is based on one of the following names for data fields. - - geohash: “geohash” - - latitude: “latitude”, “lat” - - longitude: “longitude”, “lng”, “lon” - - lookup: “lookup” -- **Coords** specifies that your query holds coordinate data. You will get prompted to select numeric data fields for latitude and longitude from your database query. -- **Geohash** specifies that your query holds geohash data. You will get prompted to select a string data field for the geohash from your database query. -- **Lookup** specifies that your query holds location name data that needs to be mapped to a value. You will get prompted to select the lookup field from your database query and a gazetteer. The gazetteer is the directory that is used to map your queried data to a geographical point. - -### Markers layer - -The markers layer allows you to display data points as different marker shapes such as circle, squares, triangles, stars, and more. - -![Markers Layer](/static/img/docs/geomap-panel/geomap-markers-8-1-0.png) - -![Markers Layer Options](/static/img/docs/geomap-panel/geomap-markers-options-8-1-0.png) - -- **Marker Color** configures the color of the marker. The default `Fixed size` keeps all points a single color. There is an alternate option to have multiple colors depending on the data point values and the threshold set at the `Thresholds` section. -- **Marker Size** configures the size of the marker. Default is `Fixed size`, making all marker size the same regardless of the data points. However, there is also an option to scale the circles to the corresponding data points. `Min` and `Max` marker size has to be set such that the Marker layer can scale within these range. -- **Marker Shape** provides you with the flexibility to visualize the data points differently. - - Circle - - Square - - Triangle - - Star - - Cross - - X -- **Fill opacity** configures the transparency of each marker. - -### Heatmap layer - -The heatmap layer clusters various data points to visualize locations with different densities. -To add a heatmap layer: - -Click on the drop-down menu under Data Layer and choose `Heatmap`. - -Similar to `Markers`, you are prompted with various options to determine which data points to visualize and how. - -![Heatmap Layer](/static/img/docs/geomap-panel/geomap-heatmap-8-1-0.png) - -![Heatmap Layer Options](/static/img/docs/geomap-panel/geomap-heatmap-options-8-1-0.png) - -- **Weight values** configures the intensity of the heatmap clusters. `Fixed value` keeps a constant weight value throughout all data points. This value should be in the range of 0~1. Similar to Markers, there is an alternate option in the drop-down to automatically scale the weight values depending on data values. -- **Radius** configures the size of the heatmap clusters. -- **Blur** configures the amount of blur on each cluster. diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index 1de6833f5c2eb..de3a4f91f664b 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -11,6 +11,7 @@ as info on deprecations, breaking changes and plugin development read the [relea ## Grafana 8 +- [What's new in 8.3]({{< relref "whats-new-in-v8-3" >}}) - [What's new in 8.2]({{< relref "whats-new-in-v8-2" >}}) - [What's new in 8.1]({{< relref "whats-new-in-v8-1" >}}) - [What's new in 8.0]({{< relref "whats-new-in-v8-0" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v2-0.md b/docs/sources/whatsnew/whats-new-in-v2-0.md index 4882bcca4eeba..67107573ac0bb 100644 --- a/docs/sources/whatsnew/whats-new-in-v2-0.md +++ b/docs/sources/whatsnew/whats-new-in-v2-0.md @@ -57,7 +57,7 @@ They're a great way to communicate about a particular incident with specific peo ### 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. +You can publish snapshots locally or to [snapshots.raintank.io](https://snapshots.raintank.io). snapshots.raintank.io is a free service provided by [Grafana Labs](https://grafana.com) for hosting external Grafana snapshots. Either way, anyone with the link (and access to your Grafana instance for local snapshots) can view it. @@ -82,11 +82,11 @@ Currently you can only override the dashboard time with relative time ranges, no 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). +Below you should see an iframe with a graph panel (taken from a Dashboard snapshot at [snapshots.raintank.io](http://snapshots.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. diff --git a/docs/sources/whatsnew/whats-new-in-v8-2.md b/docs/sources/whatsnew/whats-new-in-v8-2.md index d682617613ebd..4a37020a09c05 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-2.md +++ b/docs/sources/whatsnew/whats-new-in-v8-2.md @@ -40,7 +40,7 @@ We have continued to improve how you manage your plugins within Grafana. The new {{< figure src="/static/img/docs/plugins/plugins-catalog-browse-8-1.png" max-width="1200px" caption="Plugins Catalog Browsers" >}} -## Grafana 8 Alerting +## Grafana Alerting We’ve continued to bolster the new, unified alerting system launched in Grafana 8. This update includes the addition of a UI to edit the Cortex/Loki namespace, edit the alert group name, and edit the alert group evaluation interval. We've also added a Test button to test an alert notification contact point. There's even more to explore here including custom grouping for alert manager notifications and several small but significant changes to improve creation editing and managing alert rules. Please see the [alerting documentation](https://grafana.com/docs/grafana/latest/alerting/unified-alerting/) for more details and information on how you can enable the unified alerting system in your instance of Grafana. diff --git a/docs/sources/whatsnew/whats-new-in-v8-3.md b/docs/sources/whatsnew/whats-new-in-v8-3.md new file mode 100644 index 0000000000000..a8a15a30a5c8e --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v8-3.md @@ -0,0 +1,103 @@ ++++ +title = "What's new in Grafana v8.3" +description = "Feature and improvement highlights for Grafana v8.3" +keywords = ["grafana", "new", "documentation", "8.3", "release notes"] +weight = -33 +aliases = ["/docs/grafana/latest/guides/whats-new-in-v8-3/"] +[_build] +list = false ++++ + +# What’s new in Grafana v8.3 + +Grafana 8.3 is an exciting release for Grafana Labs. This release includes the new Candlestick Panel, a new visualization suggestions engine and, for enterprise users, Recorded Queries. + +For Open Source users it also marks the first time Grafana Alerting, formerly unified alerting, is enabled by default for new Grafana installations. Grafana Alerting in 8.3 is the flexible, single pane of glass for all your alerts. Included in this release is expanded provisioning support for notifiers, contact points, and alert rules, alongside auditing and fine-grained access control for our Enterprise customers. + +We’ve summarized what’s new in the release here, but you might also be interested in the announcement blog post as well. If you’d like all the details you can check out the complete [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +# Grafana OSS + +## Community Contributions + +Grafana 8.3 includes many community contributions including the ability to interpolate variables in tags ([#42318](https://github.com/grafana/grafana/pull/42318)), fly-out sub-menu support ([#41647](https://github.com/grafana/grafana/pull/41647)), the addition of AWS RoboMaker & AWS Global Accelerator metrics to the AWS CloudWatch data source ([#41450](https://github.com/grafana/grafana/pull/41450), [#41236](https://github.com/grafana/grafana/pull/41236)), as well as updates to the news panel ([#41193](https://github.com/grafana/grafana/pull/41193)), the plugin catalog ([#40917](https://github.com/grafana/grafana/pull/40917)), and many more. The Grafana Labs team is deeply grateful for all those who take the time to contribute and participate in the development of Grafana. + +## Accessibility + +We’ve continued to make progress on improving Grafana’s accessibility. In Grafana 8.3 we’ve updated the main menu with improved keyboard navigation. We’ve also improved Grafana’s behavior when navigating through elements with the tab key (tab stops) and finished our work to make Grafana’s viewer roles compatible with assistive technologies such as screen readers. You can read our accessibility statement here and reach out to us with accessibility issues using our community Slack or our community forums. + +## Dashboards and Visualizations + +### Panel Suggestions + +Grafana will now make suggestions for visualization types based on the current data surfaced by a query or queries in a panel. This makes seeing available, possible interpretations of your data more straightforward and can be a useful jumping-off point when building panels and dashboards with specific goals in mind. + +{{< figure src="/static/img/docs/panels/panel_suggestions_8_3.png" max-width="1200px" caption="Panel suggestionss" >}} + +### Candlestick Panel (Beta) + +Grafana 8.3 includes a new Candlestick panel that is so much more. Beyond candlesticks and open, high, low, close, behaviors, it includes customizable up/down colors, bar color determined by intra-period or inter-period movement of the data, volume histogram with matching colors, and the ability to detach or create a separate volume histogram to allow for more flexible dashboard design. The candlestick panel is based upon our time series panel meaning it also has support for additional data with the same configuration options and styles. + +{{< figure src="/static/img/docs/candlestick-panel/candlestick-panel-8-3.png" max-width="1200px" caption="Candlestick panel" >}} + +### Geomap updates + +The Geomap panel has been updated to support multiple data layers. The panel can now be configured with multiple markers, heatmaps & GeoJSON layers. The layers can be individually configured, named, and re-ordered to create rich, multidimensional visualizations. Please see the documentation for details on how you can set up and configure these new configuration options. + +{{< figure src="/static/img/docs/geomap-panel/geomap_8_3.png" max-width="1200px" caption="Geomap panel updates" >}} + +## Grafana Alerting + +Grafana Alerting is now the default alerting experience for all new Open Source installations of Grafana 8.3. Grafana Alerting in 8.3 includes the ability to test contact points and notification routing. Grafana 8.3 also adds the ability to configure and use external, Prometheus-style alert managers from within the Grafana Alerting workflow. + +{{< figure src="/static/img/docs/alerting/alerting_8_0.png" max-width="1200px" caption="Grafana Alerting" >}} + +## Support for AWS CloudWatch Metrics Insights + +Grafana and Amazon Managed Grafana now support AWS Metrics Insights – a fast, flexible, SQL-based query engine that enables you to identify trends and patterns across millions of operational metrics in real time. + +You can use Metrics Insights in the AWS CloudWatch plugin. Using this new feature is as simple as selecting the Metric Query type. The Metric Query type has two different modes: a Builder mode and a Code editor mode. + +The example below demonstrates using the new Metrics Insight capability to view the top 5 instances with the highest average CPU Utilization, ordered by maximum, in descending order. The code editor has built-in autocompletion support that gives suggestions throughout the composition of the query. + +{{< figure src="/static/img/docs/cloudwatch/cloudwatch-code-editor-autocomplete-8.3.0.gif" max-width="1200px" caption="Grafana Alerting" >}} + +# Grafana Enterprise + +## Recorded Queries + +Recorded queries turn “point in time” data into time series. + +Recorded queries allow you to export the results of certain non-time series queries to the Enterprise backend in order to store data over time and allow customers to construct their own time series. + +This new feature is especially helpful for Enterprise customers using plugins because many new plugins, like ServiceNow and Jira, don’t return time series so customers weren’t able to plot historical data over time. With recorded queries, now they can! For more information + +## Assign fine-grained permissions directly to users with the new role picker (beta) + +Sometimes the Viewer, Editor, and Admin roles just don’t fit what a certain user needs to do in Grafana. Now you can assign fine-grained roles directly to users, so they can create reports, use Explore mode, create data sources, and perform other specific actions in Grafana. The role picker can be access from the Grafana Admin user management page. + +{{< figure src="/static/img/docs/enterprise/enterprise_role_picker_8_3.png" max-width="1200px" caption="Grafana Enterprise Role Picker" >}} + +## Use fine-grained access control for Organizations and Licensing (beta) + +We’ve added new permissions to fine-grained access control to help you specify actions that users can perform. Now you can assign permissions to manage Organizations and License functions in Grafana, in addition to Users, Data Sources, Reports, and other resources. Fine-grained access control remains in beta and we will continue to add new permissions until all of Grafana’s endpoints are covered. For a complete list of the actions you can permit using fine-grained access control, see the [reference](https://grafana.com/docs/grafana/next/enterprise/access-control/fine-grained-access-control-references/). + +## Get your encryption key from a Key Management Service + +Grafana’s database contains secrets, like the credentials used to query data sources, send alert notifications and perform other functions within Grafana. These secrets are encrypted using keys, which are usually stored in Grafana’s configuration file. Now you can get your encryption key from Amazon KMS, Azure Key Vault, or Hashicorp Vault. This allows you to centrally manage your Grafana encryption key and reduce the chances it will leak. + +In order to support this, we’ve upgraded Grafana Enterprise to use envelope encryption, which complements the KMS integration by adding a layer of indirection to the encryption process. Instead of encrypting all secrets with a single key, Grafana uses a set of keys called data encryption keys (DEKs) to encrypt them. These data encryption keys are themselves encrypted with a single key encryption key (KEK). With envelope encryption, you can store a KEK in your KMS, and still quickly encrypt and decrypt data using DEKs stored within the Grafana database. + +## Pay the same for all users, regardless of their permissions + +Are you tired of managing user permissions because your license only allows a certain number of Viewers and Editors or Admins? So were we. We’ve added support for combined user pricing, where all users cost the same and fall into the same license bucket in Grafana Enterprise. This is a specific license option and must be updated in your contract. To learn more, refer to our [licensing docs](https://grafana.com/docs/grafana/latest/enterprise/license/license-restrictions/). To switch to combined user pricing, contact your Grafana Labs account team. + +{{< figure src="/static/img/docs/enterprise/enterprise_users_8_3.png" max-width="1200px" caption="Grafana Enterprise User Pricing" >}} + +## Author dashboards faster with resource caching + +Your query editor just became faster. [Query caching](https://grafana.com/docs/grafana/latest/enterprise/query-caching/) improves query performance and sometimes reduces cost, by reducing the number of repetitive queries performed against data sources. Resource caching does the same thing but for resource calls, like retrieving the list of applications in the AppDynamics editor, the list of metrics from Datadog, or the list of values in a template variable dropdown. This makes for a zippier user experience for everyone writing queries in Grafana. + +## Review audit logs for more services, like alerting + +[Audit logs](https://grafana.com/docs/grafana/latest/enterprise/auditing/) are a record of the actions users perform in Grafana, which you can investigate in case of a security incident or to understand Grafana usage better. We’ve added audit logs for new actions performed against plugins, data sources, library elements, and Grafana’s new alerting service. This ensures that if a user makes a change anywhere in Grafana Enterprise, you’ll have a record of it. For details, refer to the [Auditing docs](https://grafana.com/docs/grafana/latest/enterprise/auditing/). diff --git a/e2e/suite1/specs/dashboard-templating.spec.ts b/e2e/dashboards-suite/dashboard-templating.spec.ts similarity index 100% rename from e2e/suite1/specs/dashboard-templating.spec.ts rename to e2e/dashboards-suite/dashboard-templating.spec.ts diff --git a/e2e/suite1/specs/dashboard-time-zone.spec.ts b/e2e/dashboards-suite/dashboard-time-zone.spec.ts similarity index 100% rename from e2e/suite1/specs/dashboard-time-zone.spec.ts rename to e2e/dashboards-suite/dashboard-time-zone.spec.ts diff --git a/e2e/suite1/specs/import-dashboard.spec.ts b/e2e/dashboards-suite/import-dashboard.spec.ts similarity index 93% rename from e2e/suite1/specs/import-dashboard.spec.ts rename to e2e/dashboards-suite/import-dashboard.spec.ts index 477a64587c3f1..e4307ff1c38a2 100644 --- a/e2e/suite1/specs/import-dashboard.spec.ts +++ b/e2e/dashboards-suite/import-dashboard.spec.ts @@ -5,7 +5,7 @@ e2e.scenario({ itName: 'Ensure you can import a number of json test dashboards from a specific test directory', addScenarioDataSource: false, addScenarioDashBoard: false, - skipScenario: false, + skipScenario: true, scenario: () => { e2e.flows.importDashboards('/dashboards', 1000); }, diff --git a/e2e/suite1/specs/templating-dashboard-links-and-variables.ts b/e2e/dashboards-suite/templating-dashboard-links-and-variables.ts similarity index 100% rename from e2e/suite1/specs/templating-dashboard-links-and-variables.ts rename to e2e/dashboards-suite/templating-dashboard-links-and-variables.ts diff --git a/e2e/suite1/dashboards/TestDashboard.json b/e2e/dashboards/TestDashboard.json similarity index 100% rename from e2e/suite1/dashboards/TestDashboard.json rename to e2e/dashboards/TestDashboard.json diff --git a/e2e/suite1/specs/panelEdit_base.spec.ts b/e2e/panels-suite/panelEdit_base.spec.ts similarity index 91% rename from e2e/suite1/specs/panelEdit_base.spec.ts rename to e2e/panels-suite/panelEdit_base.spec.ts index b34acb1bc3cf1..5bc8545c12f33 100644 --- a/e2e/suite1/specs/panelEdit_base.spec.ts +++ b/e2e/panels-suite/panelEdit_base.spec.ts @@ -7,9 +7,11 @@ e2e.scenario({ itName: 'Tests various Panel edit scenarios', addScenarioDataSource: false, addScenarioDashBoard: false, - skipScenario: false, + skipScenario: true, scenario: () => { + e2e().intercept('/api/ds/query').as('query'); e2e.flows.openDashboard({ uid: 'TkZXxlNG3' }); + e2e().wait('@query'); e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST); @@ -43,13 +45,12 @@ e2e.scenario({ // 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.Tab.active().should('have.text', 'Alert0'); // there's no alert so therefore Alert + 0 + e2e.components.AlertTab.content().should('not.exist'); e2e.components.QueryTab.content().should('not.exist'); e2e.components.TransformTab.content().should('not.exist'); - e2e.components.PanelAlertTabContent.content().should('not.exist'); + e2e.components.PanelAlertTabContent.content().should('exist'); + e2e.components.PanelAlertTabContent.content().should('be.visible'); e2e.components.Tab.title('Query').should('be.visible').click(); }); diff --git a/e2e/suite1/specs/panelEdit_queries.spec.ts b/e2e/panels-suite/panelEdit_queries.spec.ts similarity index 98% rename from e2e/suite1/specs/panelEdit_queries.spec.ts rename to e2e/panels-suite/panelEdit_queries.spec.ts index 8694905d78fe9..0276011ae2fb7 100644 --- a/e2e/suite1/specs/panelEdit_queries.spec.ts +++ b/e2e/panels-suite/panelEdit_queries.spec.ts @@ -1,5 +1,5 @@ import { e2e } from '@grafana/e2e'; -import { expect } from '../../../public/test/lib/common'; +import { expect } from '../../public/test/lib/common'; const flakyTimeout = 10000; diff --git a/e2e/suite1/specs/panelEdit_transforms.spec.ts b/e2e/panels-suite/panelEdit_transforms.spec.ts similarity index 100% rename from e2e/suite1/specs/panelEdit_transforms.spec.ts rename to e2e/panels-suite/panelEdit_transforms.spec.ts diff --git a/e2e/run-suite b/e2e/run-suite index f9fad1f8da326..dad79eb257b95 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -1,32 +1,42 @@ #!/bin/bash set -xeo pipefail -. e2e/variables +. scripts/grafana-server/variables HOST=${HOST:-$DEFAULT_HOST} PORT=${PORT:-$DEFAULT_PORT} echo -e "Starting Cypress scenarios" +args=("$@") + 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="--no-exit" -fi - -if [ "$1" == "dev" ]; then - echo "Dev mode" - CMD="open" -fi +integrationFolder=../../e2e +testFiles=*-suite/*spec.ts cd packages/grafana-e2e +case "$1" in + "debug") + echo -e "Debug mode" + SLOWMO=1 + PARAMS="--no-exit" + ;; + "dev") + echo "Dev mode" + CMD="open" + ;; + "") + ;; + *) + integrationFolder=../../e2e/"${args[0]}" + testFiles="*.spec.ts" + ;; +esac + 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,videoUploadOnPasses=false \ + --config defaultCommandTimeout=30000,testFiles=$testFiles,integrationFolder=$integrationFolder,screenshotsFolder=../../e2e/"${args[0]}"/screenshots,videosFolder=../../e2e/"${args[0]}"/videos,fileServerFolder=./cypress,viewportWidth=1920,viewportHeight=1080,trashAssetsBeforeRuns=false,videoUploadOnPasses=false \ $PARAMS diff --git a/e2e/suite1/tsconfig.json b/e2e/shared/tsconfig.json similarity index 100% rename from e2e/suite1/tsconfig.json rename to e2e/shared/tsconfig.json diff --git a/e2e/suite1/specs/1-smoketests.spec.ts b/e2e/smoke-tests-suite/1-smoketests.spec.ts similarity index 50% rename from e2e/suite1/specs/1-smoketests.spec.ts rename to e2e/smoke-tests-suite/1-smoketests.spec.ts index 185eb389e0f7c..2049b329695a8 100644 --- a/e2e/suite1/specs/1-smoketests.spec.ts +++ b/e2e/smoke-tests-suite/1-smoketests.spec.ts @@ -1,4 +1,4 @@ import { e2e } from '@grafana/e2e'; -import { smokeTestScenario } from '../../shared/smokeTestScenario'; +import { smokeTestScenario } from '../shared/smokeTestScenario'; e2e.scenario(smokeTestScenario); diff --git a/e2e/suite1/specs/variables/load-options-from-url.ts b/e2e/smoke-tests-suite/variables/load-options-from-url.ts similarity index 100% rename from e2e/suite1/specs/variables/load-options-from-url.ts rename to e2e/smoke-tests-suite/variables/load-options-from-url.ts diff --git a/e2e/suite1/specs/variables/new-query-variable.ts b/e2e/smoke-tests-suite/variables/new-query-variable.ts similarity index 100% rename from e2e/suite1/specs/variables/new-query-variable.ts rename to e2e/smoke-tests-suite/variables/new-query-variable.ts diff --git a/e2e/suite1/specs/variables/set-options-from-ui.ts b/e2e/smoke-tests-suite/variables/set-options-from-ui.ts similarity index 100% rename from e2e/suite1/specs/variables/set-options-from-ui.ts rename to e2e/smoke-tests-suite/variables/set-options-from-ui.ts diff --git a/e2e/suite1/specs/variables/textbox-variables.ts b/e2e/smoke-tests-suite/variables/textbox-variables.ts similarity index 100% rename from e2e/suite1/specs/variables/textbox-variables.ts rename to e2e/smoke-tests-suite/variables/textbox-variables.ts diff --git a/e2e/start-and-run-suite b/e2e/start-and-run-suite index 9aac995b61f0e..2c5c7d1d9964b 100755 --- a/e2e/start-and-run-suite +++ b/e2e/start-and-run-suite @@ -1,13 +1,13 @@ #!/bin/bash -. e2e/variables +. scripts/grafana-server/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 + ./scripts/grafana-server/start-server 2>&1 > scripts/grafana-server/server.log & + ./scripts/grafana-server/wait-for-grafana fi ./e2e/run-suite "$@" diff --git a/e2e/suite1/screenshots/bar-gauge.spec.ts/bar-gauge-gradient (1).png b/e2e/suite1/screenshots/bar-gauge.spec.ts/bar-gauge-gradient (1).png deleted file mode 100644 index 08b192e69a33c..0000000000000 Binary files a/e2e/suite1/screenshots/bar-gauge.spec.ts/bar-gauge-gradient (1).png and /dev/null differ diff --git a/e2e/suite1/screenshots/bar-gauge.spec.ts/bar-gauge-gradient.png b/e2e/suite1/screenshots/bar-gauge.spec.ts/bar-gauge-gradient.png deleted file mode 100644 index e15f82cb14b0f..0000000000000 Binary files a/e2e/suite1/screenshots/bar-gauge.spec.ts/bar-gauge-gradient.png and /dev/null differ diff --git a/e2e/suite1/screenshots/expected/bar-gauge-gradient.png b/e2e/suite1/screenshots/expected/bar-gauge-gradient.png deleted file mode 100644 index e15f82cb14b0f..0000000000000 Binary files a/e2e/suite1/screenshots/expected/bar-gauge-gradient.png and /dev/null differ diff --git a/e2e/suite1/specs/bar-gauge.spec.ts b/e2e/various-suite/bar-gauge.spec.ts similarity index 100% rename from e2e/suite1/specs/bar-gauge.spec.ts rename to e2e/various-suite/bar-gauge.spec.ts diff --git a/e2e/suite1/specs/exemplars.spec.ts b/e2e/various-suite/exemplars.spec.ts similarity index 97% rename from e2e/suite1/specs/exemplars.spec.ts rename to e2e/various-suite/exemplars.spec.ts index 7a8c77921b738..2fb174b96e29d 100644 --- a/e2e/suite1/specs/exemplars.spec.ts +++ b/e2e/various-suite/exemplars.spec.ts @@ -4,7 +4,7 @@ const dataSourceName = 'PromExemplar'; const addDataSource = () => { e2e.flows.addDataSource({ type: 'Prometheus', - expectedAlertMessage: 'Bad Gateway', + expectedAlertMessage: 'Error reading Prometheus', name: dataSourceName, form: () => { e2e.components.DataSource.Prometheus.configPage.exemplarsAddButton().click(); diff --git a/e2e/suite1/specs/explore.spec.ts b/e2e/various-suite/explore.spec.ts similarity index 100% rename from e2e/suite1/specs/explore.spec.ts rename to e2e/various-suite/explore.spec.ts diff --git a/e2e/suite1/specs/gauge.spec.ts b/e2e/various-suite/gauge.spec.ts similarity index 100% rename from e2e/suite1/specs/gauge.spec.ts rename to e2e/various-suite/gauge.spec.ts diff --git a/e2e/suite1/specs/inspect-drawer.spec.ts b/e2e/various-suite/inspect-drawer.spec.ts similarity index 100% rename from e2e/suite1/specs/inspect-drawer.spec.ts rename to e2e/various-suite/inspect-drawer.spec.ts diff --git a/e2e/suite1/specs/pie-chart.spec.ts b/e2e/various-suite/pie-chart.spec.ts similarity index 100% rename from e2e/suite1/specs/pie-chart.spec.ts rename to e2e/various-suite/pie-chart.spec.ts diff --git a/e2e/suite1/specs/query-editor.spec.ts b/e2e/various-suite/query-editor.spec.ts similarity index 100% rename from e2e/suite1/specs/query-editor.spec.ts rename to e2e/various-suite/query-editor.spec.ts diff --git a/e2e/suite1/specs/select-focus.spec.ts b/e2e/various-suite/select-focus.spec.ts similarity index 100% rename from e2e/suite1/specs/select-focus.spec.ts rename to e2e/various-suite/select-focus.spec.ts diff --git a/e2e/suite1/specs/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts similarity index 100% rename from e2e/suite1/specs/solo-route.spec.ts rename to e2e/various-suite/solo-route.spec.ts diff --git a/e2e/suite1/specs/trace-view-scrolling.spec.ts b/e2e/various-suite/trace-view-scrolling.spec.ts similarity index 100% rename from e2e/suite1/specs/trace-view-scrolling.spec.ts rename to e2e/various-suite/trace-view-scrolling.spec.ts diff --git a/e2e/suite1/specs/visualization-suggestions.ts b/e2e/various-suite/visualization-suggestions.spec.ts similarity index 100% rename from e2e/suite1/specs/visualization-suggestions.ts rename to e2e/various-suite/visualization-suggestions.spec.ts diff --git a/e2e/verify-release b/e2e/verify-release index fb923d5d874f1..2bb9df037fa8a 100755 --- a/e2e/verify-release +++ b/e2e/verify-release @@ -1,5 +1,5 @@ #!/bin/bash -. e2e/variables +. scripts/grafana-server/variables -SUITE=verify ./e2e/run-suite +./e2e/run-suite verify/specs diff --git a/emails/templates/ng_alert_notification.html b/emails/templates/ng_alert_notification.html index ec700434b715f..1f967e5335396 100644 --- a/emails/templates/ng_alert_notification.html +++ b/emails/templates/ng_alert_notification.html @@ -1,6 +1,11 @@ [[Subject .Subject "[[.Title]]"]] [[ define "alert" ]] + + + Value: [[ .ValueString ]] + + [[ if gt (len .Annotations.SortedPairs) 0 ]] @@ -108,6 +113,16 @@ margin: 0 10px 0 0; padding: 5px 9px; } + .value { + font-size: 14px; + padding-top: 24px; + } + .value-heading { + font-weight: bold; + } + .value-value { + padding-left: 8px; + } .annotations { font-size: 14px; padding: 24px 0 12px 0; diff --git a/go.mod b/go.mod index 4d29146879a35..c39891732087d 100644 --- a/go.mod +++ b/go.mod @@ -48,14 +48,14 @@ require ( github.com/gogo/protobuf v1.3.2 github.com/golang/mock v1.6.0 github.com/golang/snappy v0.0.4 - github.com/google/go-cmp v0.5.6 + github.com/google/go-cmp v0.5.7 github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 github.com/gorilla/websocket v1.4.2 github.com/gosimple/slug v1.9.0 github.com/grafana/cuetsy v0.0.0-20210928021233-5ddfb47f9a7d github.com/grafana/grafana-aws-sdk v0.7.0 - github.com/grafana/grafana-plugin-sdk-go v0.116.0 + github.com/grafana/grafana-plugin-sdk-go v0.118.0 github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/hashicorp/go-hclog v0.16.1 @@ -81,7 +81,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/browser v0.0.0-20210904010418-6d279e18f982 // indirect github.com/pkg/errors v0.9.1 - github.com/prometheus/alertmanager v0.23.1-0.20211021072955-1b8afe7cb5aa + github.com/prometheus/alertmanager v0.23.1-0.20211116083607-e2a10119aaf7 github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_model v0.2.0 github.com/prometheus/common v0.32.1 @@ -92,7 +92,7 @@ require ( 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/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f github.com/uber/jaeger-client-go v2.29.1+incompatible github.com/unknwon/com v1.0.1 github.com/urfave/cli/v2 v2.3.0 @@ -107,12 +107,12 @@ require ( go.opentelemetry.io/otel/trace v1.0.0 golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e golang.org/x/exp v0.0.0-20210220032938-85be41e4509f // indirect - golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac - golang.org/x/tools v0.1.5 - gonum.org/v1/gonum v0.9.1 + golang.org/x/tools v0.1.9 + gonum.org/v1/gonum v0.11.0 google.golang.org/api v0.57.0 google.golang.org/grpc v1.41.0 google.golang.org/protobuf v1.27.1 @@ -225,7 +225,7 @@ require ( github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common/sigv4 v0.1.0 // indirect - github.com/prometheus/exporter-toolkit v0.6.1 // indirect + github.com/prometheus/exporter-toolkit v0.7.0 // indirect github.com/prometheus/node_exporter v1.0.0-rc.0.0.20200428091818-01054558c289 // indirect github.com/prometheus/procfs v0.6.0 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20201118171849-f6a6b3f636fc // indirect @@ -250,7 +250,7 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/goleak v1.1.10 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect - golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365 // indirect + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect @@ -258,6 +258,28 @@ require ( gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect ) +require ( + github.com/Microsoft/go-winio v0.4.17 // indirect + github.com/Microsoft/hcsshim v0.8.23 // indirect + github.com/bits-and-blooms/bitset v1.2.0 // indirect + github.com/containerd/cgroups v1.0.1 // indirect + github.com/containerd/containerd v1.5.10 // indirect + github.com/containerd/continuity v0.1.0 // indirect + github.com/containerd/fifo v1.0.0 // indirect + github.com/containerd/ttrpc v1.1.0 // indirect + github.com/containerd/typeurl v1.0.2 // indirect + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/klauspost/compress v1.13.1 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/sys/mountinfo v0.4.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.2 // indirect + github.com/opencontainers/runc v1.0.2 // indirect + github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 // indirect + github.com/opencontainers/selinux v1.8.2 // indirect +) + // Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream replace github.com/crewjam/saml => github.com/grafana/saml v0.0.0-20211007135653-aed1b2edd86b diff --git a/go.sum b/go.sum index 3dbfe6fe16a7f..652e734ac053f 100644 --- a/go.sum +++ b/go.sum @@ -214,6 +214,8 @@ github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2 github.com/Microsoft/hcsshim v0.8.15/go.mod h1:x38A4YbHbdxJtc0sF6oIz+RG0npwSCAvn69iY6URG00= github.com/Microsoft/hcsshim v0.8.16/go.mod h1:o5/SZqmR7x9JNKsW3pu+nqHm0MF8vbA+VxGOoXdC600= github.com/Microsoft/hcsshim v0.8.18/go.mod h1:+w2gRZ5ReXQhFOrvSQeNfhrYB/dg3oDwTOcER2fw4I4= +github.com/Microsoft/hcsshim v0.8.23 h1:47MSwtKGXet80aIn+7h4YI6fwPmwIghAnsx2aOUrG2M= +github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01nnU2M8jKDg= github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU= github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= @@ -350,6 +352,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB 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/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bits-and-blooms/bitset v1.2.0 h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA= +github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= @@ -403,6 +407,7 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= +github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= 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= @@ -415,6 +420,7 @@ github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmE github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= +github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= 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= @@ -453,6 +459,7 @@ github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1 github.com/containerd/cgroups v0.0.0-20200710171044-318312a37340/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo= github.com/containerd/cgroups v0.0.0-20200824123100-0b889c03f102/go.mod h1:s5q4SojHctfxANBDvMeIaIovkq29IP48TKAxnhYRxvo= github.com/containerd/cgroups v0.0.0-20210114181951-8a68de567b68/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= +github.com/containerd/cgroups v1.0.1 h1:iJnMvco9XGvKUvNQkv88bE4uJXxRQH18efbKo9w5vHQ= github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU= github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= github.com/containerd/console v0.0.0-20181022165439-0650fd9eeb50/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= @@ -469,25 +476,31 @@ github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMX github.com/containerd/containerd v1.4.0-beta.2.0.20200729163537-40b22ef07410/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/containerd v1.4.9/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.5.0-beta.1/go.mod h1:5HfvG1V2FsKesEGQ17k5/T7V960Tmcumvqn8Mc+pCYQ= github.com/containerd/containerd v1.5.0-beta.3/go.mod h1:/wr9AVtEM7x9c+n0+stptlo/uBBoBORwEx6ardVcmKU= github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09ZvgqEq8EfBp/m3lcVZIvPHhI= github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s= github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= -github.com/containerd/containerd v1.5.4 h1:uPF0og3ByFzDnaStfiQj3fVGTEtaSNyU+bW7GR/nqGA= github.com/containerd/containerd v1.5.4/go.mod h1:sx18RgvW6ABJ4iYUw7Q5x7bgFOAB9B6G7+yO0XBc4zw= +github.com/containerd/containerd v1.5.8 h1:NmkCC1/QxyZFBny8JogwLpOy2f+VEbO/f6bV2Mqtwuw= +github.com/containerd/containerd v1.5.8/go.mod h1:YdFSv5bTFLpG2HIYmfqDpSYYTDX+mc5qtSuYx1YUb/s= +github.com/containerd/containerd v1.5.10 h1:3cQ2uRVCkJVcx5VombsE7105Gl9Wrl7ORAO3+4+ogf4= +github.com/containerd/containerd v1.5.10/go.mod h1:fvQqCfadDGga5HZyn3j4+dx56qj2I9YwBrlSdalvJYQ= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20200710164510-efbc4488d8fe/go.mod h1:cECdGN1O8G9bgKTlLhuPJimka6Xb/Gg7vYzCTNVxhvo= github.com/containerd/continuity v0.0.0-20201208142359-180525291bb7/go.mod h1:kR3BEg7bDFaEddKm54WSmrol1fKWDU1nKYkgrcgZT7Y= github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ= +github.com/containerd/continuity v0.1.0 h1:UFRRY5JemiAhPZrr/uE0n8fMTLcZsUvySPr1+D7pgr8= github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM= github.com/containerd/fifo v0.0.0-20180307165137-3d5202aec260/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= github.com/containerd/fifo v0.0.0-20200410184934-f15a3290365b/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c/go.mod h1:jPQ2IAeZRCYxpS/Cm1495vGFww6ecHmMk1YJH2Q5ln0= github.com/containerd/fifo v0.0.0-20210316144830-115abcc95a1d/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= +github.com/containerd/fifo v1.0.0 h1:6PirWBr9/L7GDamKr+XM0IeUFXu5mf3M/BPpH9gaLBU= github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= github.com/containerd/go-cni v1.0.1/go.mod h1:+vUpYxKvAF72G9i1WoDOiPGRtQpqsNW/ZHtSlv++smU= github.com/containerd/go-cni v1.0.2/go.mod h1:nrNABBHzu0ZwCug9Ije8hL2xBCYh/pjfMb1aZGrrohk= @@ -508,9 +521,12 @@ github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDG github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8= github.com/containerd/ttrpc v1.0.1/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= github.com/containerd/ttrpc v1.0.2/go.mod h1:UAxOpgT9ziI0gJrmKvgcZivgxOp8iFPSk8httJEt98Y= +github.com/containerd/ttrpc v1.1.0 h1:GbtyLRxb0gOLR0TYQWt3O6B0NvT8tMdorEHqIQo/lWI= +github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ= github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= github.com/containerd/typeurl v0.0.0-20190911142611-5eb25027c9fd/go.mod h1:GeKYzf2pQcqv7tJ0AoCuuhtnqhva5LNU3U+OyKxxJpk= github.com/containerd/typeurl v1.0.1/go.mod h1:TB1hUtrpaiO88KEK56ijojHS1+NeF0izUACaJW2mdXg= +github.com/containerd/typeurl v1.0.2 h1:Chlt8zIieDbzQFzXzAeBEF92KhExuE4p9p92/QmY7aY= github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= github.com/containerd/zfs v0.0.0-20200918131355-0a33824f23a2/go.mod h1:8IgZOBdv8fAgXddBT4dBXJPtxyRsejFIpXoklgxgEjw= github.com/containerd/zfs v0.0.0-20210301145711-11e8f1707f62/go.mod h1:A9zfAbMlQwE+/is6hi0Xw8ktpL+6glmqZYtevJgaB8Y= @@ -659,6 +675,7 @@ github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-events v0.0.0-20170721190031-9461782956ad/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= @@ -808,6 +825,7 @@ github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7 github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc= github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +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.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= @@ -1108,6 +1126,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 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 v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= @@ -1195,6 +1215,7 @@ github.com/gophercloud/gophercloud v0.20.0 h1:1+4jrsjVhdX5omlAo4jkmFc6ftLbuXLzgF github.com/gophercloud/gophercloud v0.20.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= @@ -1227,8 +1248,8 @@ github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58 h1:2 github.com/grafana/grafana-google-sdk-go v0.0.0-20211104130251-b190293eaf58/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE= github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CFfpMFKwAdIUZ1q8ssuLeF60= github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/grafana-plugin-sdk-go v0.116.0 h1:I3BN7tc2jdXNRI8/3g+Pmf/NgJFbSudQslMESF10qD0= -github.com/grafana/grafana-plugin-sdk-go v0.116.0/go.mod h1:F3F8qNzdsf6e+Yq35AgwaNOiCv+txpzGURyz+gJ5CuM= +github.com/grafana/grafana-plugin-sdk-go v0.118.0 h1:n2hU2Hkq0vzUG7g1meS77/yvZZWgEPiPtGuG4I1Jl94= +github.com/grafana/grafana-plugin-sdk-go v0.118.0/go.mod h1:Mhy+5mC6rSqEhnzop1wEh//n/fgkzNK5pRgg3DfCp4g= github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa h1:+pXjAxavVR2FKKNsuuCXGCWEj8XGc1Af6SPiyBpzU2A= github.com/grafana/loki v1.6.2-0.20211015002020-7832783b1caa/go.mod h1:0O8o/juxNSKN/e+DzWDTRkl7Zm8CkZcz0NDqEdojlrk= github.com/grafana/saml v0.0.0-20211007135653-aed1b2edd86b h1:YiSGp34F4V0G08HHx1cJBf2GVgwYAkXQjzuVs1t8jYk= @@ -1407,8 +1428,9 @@ github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJ github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/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= @@ -1549,6 +1571,7 @@ github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYs github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.1 h1:wXr2uRxZTJXHLly6qhJabee5JqIhTRoLBhDOA74hDEQ= github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= @@ -1757,9 +1780,11 @@ github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= github.com/mna/redisc v1.3.2 h1:sc9C+nj6qmrTFnsXb70xkjAHpXKtjjBuE6v2UcQV0ZE= github.com/mna/redisc v1.3.2/go.mod h1:CplIoaSTDi5h9icnj4FLbRgHoNKCHDNJDVRztWDGeSQ= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= +github.com/moby/sys/mountinfo v0.4.1 h1:1O+1cHA1aujwEwwVMa2Xm2l+gIpUHyd3+D+d7LZh1kM= github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ= github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= @@ -1871,21 +1896,28 @@ github.com/opencontainers/go-digest v1.0.0-rc1.0.20180430190053-c9281466c8b2/go. github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.0.0-rc93/go.mod h1:3NOsor4w32B2tC0Zbl8Knk4Wg84SM2ImC1fxBuqJ/H0= +github.com/opencontainers/runc v1.0.2 h1:opHZMaswlyxz1OuGpBE53Dwe4/xF7EZTY0A2L/FpCOg= +github.com/opencontainers/runc v1.0.2/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0= github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 h1:3snG66yBm59tKhhSPQrQ/0bCrv1LQbKt40LnUPiUxdc= +github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE= github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo= +github.com/opencontainers/selinux v1.8.2 h1:c4ca10UMgRcvZ6h0K4HtS15UaVSBEaE+iln2LVpAuGc= +github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8= 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= @@ -1967,8 +1999,8 @@ github.com/prometheus/alertmanager v0.21.1-0.20210422101724-8176f78a70e1/go.mod github.com/prometheus/alertmanager v0.22.2/go.mod h1:rYinOWxFuCnNssc3iOjn2oMTlhLaPcUuqV5yk5JKUAE= github.com/prometheus/alertmanager v0.23.0/go.mod h1:0MLTrjQI8EuVmvykEhcfr/7X0xmaDAZrqMgxIq3OXHk= github.com/prometheus/alertmanager v0.23.1-0.20210914172521-e35efbddb66a/go.mod h1:U7pGu+z7A9ZKhK8lq1MvIOp5GdVlZjwOYk+S0h3LSbA= -github.com/prometheus/alertmanager v0.23.1-0.20211021072955-1b8afe7cb5aa h1:KKgVswVOfDYOn9GNtO7bR3r4vyM77WXuJsyGxMl1Zgs= -github.com/prometheus/alertmanager v0.23.1-0.20211021072955-1b8afe7cb5aa/go.mod h1:pRqzxS2B4tciJfx2JUvR67udJrQeSUZ603OQQFFUrIQ= +github.com/prometheus/alertmanager v0.23.1-0.20211116083607-e2a10119aaf7 h1:OMwDo53awRp+UzaBrwmVC7HJiAMYP/niBJfKcGpPiac= +github.com/prometheus/alertmanager v0.23.1-0.20211116083607-e2a10119aaf7/go.mod h1:1UH4XA4DAXzsvofKVzcXmC0mqt6Y8BZP9JcQWKDmbFc= github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 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= @@ -2030,8 +2062,9 @@ github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdD github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= 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/exporter-toolkit v0.6.1 h1:Aqk75wQD92N9CqmTlZwjKwq6272nOGrWIbc8Z7+xQO0= github.com/prometheus/exporter-toolkit v0.6.1/go.mod h1:ZUBIj498ePooX9t/2xtDjeQYwvRpiPP2lh5u4iblj2g= +github.com/prometheus/exporter-toolkit v0.7.0 h1:XtYeVeeC5daG4txbc9+mieKq+/AK4gtIBLl9Mulrjnk= +github.com/prometheus/exporter-toolkit v0.7.0/go.mod h1:ZUBIj498ePooX9t/2xtDjeQYwvRpiPP2lh5u4iblj2g= 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-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -2187,6 +2220,7 @@ github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHei 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/snowflakedb/gosnowflake v1.3.4/go.mod h1:NsRq2QeiMUuoNUJhp5Q6xGC4uBrsS9g6LwZVEkTWgsE= @@ -2294,8 +2328,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20200427203606-3cfed13b9966/go.mod h1 github.com/tonistiigi/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:Q5IRRDY+cjIaiOjTAnXN5LKQV5MPqVx5ofQn85Jy5Yw= github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= 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/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f h1:A+MmlgpvrHLeUP8dkBVn4Pnf5Bp5Yk2OALm7SEJLLE8= +github.com/ua-parser/uap-go v0.0.0-20211112212520-00c877edfe0f/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E= github.com/uber-go/tally v3.3.15+incompatible/go.mod h1:YDTIBxdXyOU/sCWilKB4bgyufu1cEi0jdVnRdxvjnmU= github.com/uber/athenadriver v1.1.4/go.mod h1:tQjho4NzXw55LGfSZEcETuYydpY1vtmixUabHkC1K/E= github.com/uber/jaeger-client-go v2.15.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= @@ -2317,6 +2351,7 @@ github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGr 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 v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= @@ -2703,6 +2738,8 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f h1:w6wWR0H+nyVpbSAQbzVEIACVyr/h8l/BEkY6Sokc7Eg= golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= @@ -2875,6 +2912,7 @@ golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -2891,6 +2929,8 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365 h1:6wSTsvPddg9gc/mVEEyk9oOAoxn+bT4Z9q1zx+4RwA4= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= @@ -3028,6 +3068,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= +golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= 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= @@ -3042,6 +3084,8 @@ 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/gonum v0.11.0 h1:f1IJhK4Km5tBJmaiJXtk/PkL4cdVX6J+tGiM187uT5E= +gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= 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= @@ -3268,6 +3312,7 @@ gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 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.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -3280,6 +3325,8 @@ gopkg.in/jcmturner/gokrb5.v7 v7.5.0/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuv 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.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= diff --git a/grafana-mixin/scripts/lint.sh b/grafana-mixin/scripts/lint.sh index f6fbeb1229cd9..fc7d2eed19cf0 100755 --- a/grafana-mixin/scripts/lint.sh +++ b/grafana-mixin/scripts/lint.sh @@ -10,4 +10,4 @@ find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' ${JSONNET_FMT} "$f" | diff -u "$f" -; \ done -mixtool lint mixin.libsonnet +#mixtool lint mixin.libsonnet diff --git a/latest.json b/latest.json index 4c84e05d18c89..d325bb2abf83e 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.2.4", - "testing": "8.2.2" + "stable": "8.3.6", + "testing": "8.4.0-beta1" } diff --git a/lerna.json b/lerna.json index feff3a7f8d232..68fcfd40542c2 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "npmClient": "yarn", "useWorkspaces": true, "packages": ["packages/*"], - "version": "8.3.0-pre" + "version": "8.3.10" } diff --git a/package.json b/package.json index 03ab65ad30a02..a2eb36e6c9862 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "license": "AGPL-3.0-only", "private": true, "name": "grafana", - "version": "8.3.0-pre", + "version": "8.3.10", "repository": "github:grafana/grafana", "scripts": { "api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js", - "build": "webpack --config scripts/webpack/webpack.prod.js", + "build": "NODE_ENV=production && webpack --config scripts/webpack/webpack.prod.js", + "build-max-memory": "NODE_OPTIONS='--max-old-space-size=5120' GENERATE_SOURCEMAP=false && yarn build", "build:nominify": "webpack --config scripts/webpack/webpack.prod.js --env noMinify=1", "dev": "webpack --progress --color --config scripts/webpack/webpack.dev.js", "e2e": "./e2e/start-and-run-suite", @@ -19,6 +20,9 @@ "lint:ts": "eslint . --ext .js,.tsx,.ts --cache", "lint:sass": "yarn stylelint '{public/sass,packages}/**/*.scss' --cache", "test:ci": "mkdir -p reports/junit && JEST_JUNIT_OUTPUT_DIR=reports/junit jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", + "jest": "jest --notify --watch", + "jest-ci": "mkdir -p reports/junit && JEST_JUNIT_OUTPUT_DIR=reports/junit jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%}", + "jest-percona-ci": "mkdir -p reports/junit && JEST_JUNIT_OUTPUT_DIR=reports/junit jest --ci --reporters=default --reporters=jest-junit -w ${TEST_MAX_WORKERS:-100%} --roots public/app/percona", "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", @@ -28,11 +32,14 @@ "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:publishTest": "lerna publish from-package --contents dist --dist-tag test --yes", + "packages:publishPrevious": "lerna publish from-package --contents dist --dist-tag previous --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 \"**/*.{scss,md,mdx}\"", + "prettier:checkDocs": "prettier --check --list-different=false --loglevel=warn \"docs/**.md\" \"packages/**.{ts,tsx,scss,md,mdx}\"", "prettier:write": "prettier --list-different \"**/*.{scss,md,mdx}\" --write", "start": "yarn themes:generate && webpack --progress --color --watch --env noTsCheck=0 --config scripts/webpack/webpack.dev.js", "start:hot": "yarn themes:generate && webpack serve --progress --color --config scripts/webpack/webpack.hot.js", @@ -41,14 +48,16 @@ "storybook": "yarn workspace @grafana/ui storybook --ci", "storybook:build": "yarn workspace @grafana/ui storybook:build", "themes:generate": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", + "test-percona": "echo NOT IMPLEMENTED", "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 test:ci && yarn grafana-toolkit node-version-check", - "postinstall": "husky install" + "postinstall": "husky install", + "ci:test-percona-frontend": "yarn run prettier:check && yarn run packages:typecheck && yarn run typecheck && yarn run jest-percona-ci" }, "grafana": { - "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-2/", + "whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v8-3/", "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" }, "lint-staged": { @@ -93,8 +102,9 @@ "@testing-library/user-event": "13.3.0", "@types/angular": "1.6.56", "@types/angular-route": "1.7.0", - "@types/classnames": "2.2.9", - "@types/clipboard": "2.0.1", + "@types/antlr4": "^4.7.1", + "@types/braintree__sanitize-url": "4.0.0", + "@types/classnames": "2.3.0", "@types/common-tags": "^1.8.0", "@types/d3": "7.0.0", "@types/d3-force": "^2.1.0", @@ -112,6 +122,7 @@ "@types/lodash": "4.14.149", "@types/logfmt": "^1.2.1", "@types/lru-cache": "^5.1.0", + "@types/md5": "^2.1.33", "@types/mousetrap": "1.6.3", "@types/node": "16.11.6", "@types/papaparse": "5.2.0", @@ -127,6 +138,7 @@ "@types/react-redux": "7.1.20", "@types/react-router-dom": "^5.1.7", "@types/react-select": "4.0.13", + "@types/react-table": "^7.0.25", "@types/react-test-renderer": "17.0.1", "@types/react-transition-group": "4.4.0", "@types/react-virtualized-auto-sizer": "1.0.0", @@ -225,10 +237,11 @@ "webpack-bundle-analyzer": "4.5.0", "webpack-cleanup-plugin": "0.5.1", "webpack-cli": "4.9.1", - "webpack-dev-server": "4.4.0", + "webpack-dev-server": "4.7.4", "webpack-merge": "5.8.0" }, "dependencies": { + "@braintree/sanitize-url": "6.0.0", "@emotion/css": "11.1.3", "@emotion/react": "11.1.5", "@grafana/aws-sdk": "0.0.3", @@ -242,14 +255,24 @@ "@opentelemetry/api": "1.0.2", "@opentelemetry/exporter-collector": "0.23.0", "@opentelemetry/semantic-conventions": "1.0.0", - "@popperjs/core": "2.5.4", + "@percona/platform-core": "1.0.7", + "@popperjs/core": "2.11.0", + "@react-aria/button": "3.3.4", "@react-aria/focus": "3.5.0", + "@react-aria/interactions": "3.7.0", + "@react-aria/menu": "3.3.0", "@react-aria/overlays": "3.7.2", - "@reduxjs/toolkit": "1.6.1", - "@sentry/browser": "5.25.0", - "@sentry/types": "5.24.2", - "@sentry/utils": "6.13.2", - "@types/ol": "^6.5.1", + "@react-aria/utils": "3.10.0", + "@react-stately/collections": "3.3.4", + "@react-stately/menu": "3.2.3", + "@react-stately/tree": "3.2.0", + "@react-types/menu": "3.4.1", + "@react-types/shared": "^3.10.1", + "@reactour/tour": "^2.10.3", + "@reduxjs/toolkit": "1.7.0", + "@sentry/browser": "6.15.0", + "@sentry/types": "6.15.0", + "@sentry/utils": "6.15.0", "@visx/event": "2.1.0", "@visx/gradient": "2.1.0", "@visx/group": "2.1.0", @@ -267,11 +290,11 @@ "brace": "0.11.1", "calculate-size": "1.1.1", "centrifuge": "2.7.5", - "classnames": "2.2.6", - "clipboard": "2.0.4", + "classnames": "2.3.1", "comlink": "4.3.1", "common-tags": "^1.8.0", "core-js": "3.10.0", + "cronstrue": "^1.114.0", "d3": "5.15.0", "d3-force": "^2.1.1", "d3-scale-chromatic": "1.5.0", @@ -284,7 +307,9 @@ "fast-json-patch": "3.1.0", "fast-text-encoding": "^1.0.0", "file-saver": "2.0.2", - "history": "4.10.1", + "final-form": "^4.20.2", + "final-form-arrays": "^3.0.2", + "history": "^4.10.1", "hoist-non-react-statics": "3.3.2", "immer": "9.0.6", "immutable": "3.8.2", @@ -320,6 +345,8 @@ "react-diff-viewer": "^3.1.1", "react-dom": "17.0.1", "react-draggable": "4.4.4", + "react-final-form": "^6.5.3", + "react-final-form-arrays": "^3.1.3", "react-grid-layout": "1.2.5", "react-highlight-words": "0.17.0", "react-hook-form": "7.5.3", @@ -332,7 +359,10 @@ "react-reverse-portal": "^2.0.1", "react-router-dom": "^5.2.0", "react-select": "4.3.0", + "react-simple-compat": "^1.2.2", + "react-sizeme": "2.6.12", "react-split-pane": "0.1.89", + "react-table": "^7.6.2", "react-transition-group": "4.4.1", "react-use": "17.2.4", "react-virtualized-auto-sizer": "1.0.2", @@ -354,11 +384,12 @@ "tether": "1.4.7", "tether-drop": "https://github.com/torkelo/drop", "tinycolor2": "1.4.1", - "uplot": "1.6.17", + "uplot": "1.6.18", "uuid": "8.3.0", "vendor": "link:./public/vendor", "visjs-network": "4.25.0", - "whatwg-fetch": "3.1.0" + "whatwg-fetch": "3.1.0", + "yaml": "^2.1.1" }, "resolutions": { "underscore": "1.12.1", @@ -367,7 +398,12 @@ "@rushstack/node-core-library": "3.42.3", "@rushstack/rig-package": "0.3.3", "@rushstack/ts-command-line": "4.10.2", - "@storybook/react/webpack": "5.58.1" + "@storybook/react/webpack": "5.63.0", + "node-forge": "1.3.0", + "@braintree/sanitize-url": "6.0.0", + "url-parse": "1.5.10", + "minimist": "1.2.6", + "browserslist": "4.20.2" }, "workspaces": { "packages": [ diff --git a/packages/README.md b/packages/README.md index 8fe4b89b63e6f..b7903607d2682 100644 --- a/packages/README.md +++ b/packages/README.md @@ -71,6 +71,7 @@ As mentioned above the `canary` releases are published to the Github package reg - When releasing a prerelease run `packages:publishNext` to publish new versions. - When releasing a stable version run `packages:publishLatest` to publish new versions. + - When releasing a test version run `packages:publishTest` to publish test versions. 5. Push version commit to the release branch. diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 00e02902bb4da..b1674c9b01a1f 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/data", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana Data Library", "keywords": [ "typescript" @@ -21,14 +21,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@braintree/sanitize-url": "5.0.2", - "@grafana/schema": "8.3.0-pre", + "@braintree/sanitize-url": "6.0.0", + "@grafana/schema": "8.3.10", "@types/d3-interpolate": "^1.4.0", "d3-interpolate": "1.4.0", "date-fns": "2.21.3", "eventemitter3": "4.0.7", "lodash": "4.17.21", - "marked": "4.0.0", + "marked": "4.0.10", "moment": "2.29.1", "moment-timezone": "0.5.33", "ol": "6.7.0", diff --git a/packages/grafana-data/src/dataframe/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts index e7d0e8f727420..29bc386967ee4 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -11,6 +11,7 @@ import { DataFrameDTO, FieldType, TableData, TimeSeries } from '../types/index'; import { dateTime } from '../datetime/moment_wrapper'; import { MutableDataFrame } from './MutableDataFrame'; import { ArrayDataFrame } from './ArrayDataFrame'; +import { getFieldTypeFromValue } from '.'; describe('toDataFrame', () => { it('converts timeseries to series', () => { @@ -118,15 +119,13 @@ describe('toDataFrame', () => { 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('Get column types from values', () => { + expect(getFieldTypeFromValue(1)).toBe(FieldType.number); + expect(getFieldTypeFromValue(1.234)).toBe(FieldType.number); + expect(getFieldTypeFromValue(NaN)).toBe(FieldType.number); + expect(getFieldTypeFromValue(3.125e7)).toBe(FieldType.number); + expect(getFieldTypeFromValue(true)).toBe(FieldType.boolean); + expect(getFieldTypeFromValue('xxxx')).toBe(FieldType.string); }); it('Guess Column Types from series', () => { diff --git a/packages/grafana-data/src/dataframe/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts index f0fee32b9fe24..4f19314cb61da 100644 --- a/packages/grafana-data/src/dataframe/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -32,7 +32,7 @@ function convertTableToDataFrame(table: TableData): DataFrame { // 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 + name: text?.length ? text : c, // 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, @@ -189,10 +189,33 @@ export function guessFieldTypeFromNameAndValue(name: string, v: any): FieldType return guessFieldTypeFromValue(v); } +/** + * Check the field type to see what the contents are + */ +export function getFieldTypeFromValue(v: any): FieldType { + if (v instanceof Date || isDateTime(v)) { + return FieldType.time; + } + + if (isNumber(v)) { + return FieldType.number; + } + + if (isString(v)) { + return FieldType.string; + } + + if (isBoolean(v)) { + return FieldType.boolean; + } + + return FieldType.other; +} + /** * Given a value this will guess the best column type * - * TODO: better Date/Time support! Look for standard date strings? + * NOTE: this is will try to see if string values can be mapped to other types (like number) */ export function guessFieldTypeFromValue(v: any): FieldType { if (v instanceof Date || isDateTime(v)) { @@ -237,7 +260,7 @@ export function guessFieldTypeForField(field: Field): FieldType | undefined { // 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) { + if (v != null) { return guessFieldTypeFromValue(v); } } diff --git a/packages/grafana-data/src/field/displayProcessor.test.ts b/packages/grafana-data/src/field/displayProcessor.test.ts index c28c2f87be812..0c301915ba883 100644 --- a/packages/grafana-data/src/field/displayProcessor.test.ts +++ b/packages/grafana-data/src/field/displayProcessor.test.ts @@ -70,11 +70,11 @@ describe('Process simple display values', () => { }); it('array of text', () => { - assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN }); + 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 }); + assertSame([1, 2, 3], processors, { text: '1, 2, 3', numeric: NaN }); }); it('empty object', () => { diff --git a/packages/grafana-data/src/field/displayProcessor.ts b/packages/grafana-data/src/field/displayProcessor.ts index 28db9b6611f3b..6abc7202714dd 100644 --- a/packages/grafana-data/src/field/displayProcessor.ts +++ b/packages/grafana-data/src/field/displayProcessor.ts @@ -1,5 +1,5 @@ // Libraries -import { toString, toNumber as _toNumber, isEmpty, isBoolean } from 'lodash'; +import { toString, toNumber as _toNumber, isEmpty, isBoolean, isArray, join } from 'lodash'; // Types import { Field, FieldType } from '../types/dataFrame'; @@ -116,6 +116,10 @@ export function getDisplayProcessor(options?: DisplayProcessorOptions): DisplayP } } + if (text == null && isArray(value)) { + text = join(value, ', '); + } + if (text == null) { text = toString(value); if (!text) { diff --git a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts index 129b659884f85..339cb0554dc71 100644 --- a/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts +++ b/packages/grafana-data/src/transformations/matchers/valueMatchers/numericMatchers.ts @@ -26,7 +26,7 @@ const isGreaterValueMatcher: ValueMatcherInfo> const isGreaterOrEqualValueMatcher: ValueMatcherInfo> = { id: ValueMatcherID.greaterOrEqual, name: 'Is greater or equal', - description: 'Match when field value is lower or greater than option.', + description: 'Match when field value is greater than or equal to option.', get: (options) => { return (valueIndex: number, field: Field) => { const value = field.values.get(valueIndex); @@ -37,7 +37,7 @@ const isGreaterOrEqualValueMatcher: ValueMatcherInfo { - return `Matches all rows where field value is lower or greater than: ${options.value}.`; + return `Matches all rows where field value is greater than or equal to: ${options.value}.`; }, isApplicable: (field) => field.type === FieldType.number, getDefaultOptions: () => ({ value: 0 }), diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index b2aafaa5cfe41..6652199abb635 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -28,4 +28,5 @@ export enum DataTransformerID { prepareTimeSeries = 'prepareTimeSeries', convertFieldType = 'convertFieldType', fieldLookup = 'fieldLookup', + extractFields = 'extractFields', } diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index da6404504ce13..1c3ada963f2d9 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -487,7 +487,7 @@ export interface DataQueryRequest { timezone: string; app: CoreApp | string; - cacheTimeout?: string; + cacheTimeout?: string | null; rangeRaw?: RawTimeRange; timeInfo?: string; // The query time description (blue text in the upper right) panelId?: number; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 4ef66f69dbff8..11135aaf44f70 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -16,6 +16,7 @@ export interface NavModelItem { section?: NavSection; showOrgSwitcher?: boolean; onClick?: () => void; + menuItemType?: NavMenuItemType; } export enum NavSection { @@ -24,6 +25,11 @@ export enum NavSection { Config = 'config', } +export enum NavMenuItemType { + Section = 'section', + Item = 'item', +} + /** * Interface used to describe different kinds of page titles and page navigation. Navmodels are usually generated in the backend and stored in Redux. */ @@ -40,6 +46,7 @@ export interface NavModel { * Describes breadcrumbs that are used in places such as data source settings., folder page and plugins page. */ breadcrumbs?: NavModelItem[]; + pageTitle?: string; } export interface NavModelBreadcrumb { diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 71cf1cc4637aa..b5a0dbdd53468 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -201,12 +201,20 @@ export interface VisualizationSuggestion { fieldConfig?: FieldConfigSource>; /** Data transformations */ transformations?: DataTransformerConfig[]; - /** Tweak for small preview */ - previewModifier?: (suggestion: VisualizationSuggestion) => void; + /** Options for how to render suggestion card */ + cardOptions?: { + /** Tweak for small preview */ + previewModifier?: (suggestion: VisualizationSuggestion) => void; + icon?: string; + imgSrc?: string; + }; /** A value between 0-100 how suitable suggestion is */ score?: VisualizationSuggestionScore; } +/** + * @alpha + */ export enum VisualizationSuggestionScore { /** We are pretty sure this is the best possible option */ Best = 100, diff --git a/packages/grafana-data/src/utils/valueMappings.test.ts b/packages/grafana-data/src/utils/valueMappings.test.ts index d6bddbf1304c0..4a2dcd6329900 100644 --- a/packages/grafana-data/src/utils/valueMappings.test.ts +++ b/packages/grafana-data/src/utils/valueMappings.test.ts @@ -67,6 +67,13 @@ const testSet2: ValueMapping[] = [ result: { text: 'Hostname $1' }, }, }, + { + type: MappingType.RegexToText, + options: { + pattern: '/hello/', + result: { color: 'red' }, + }, + }, ]; describe('Format value with value mappings', () => { @@ -164,6 +171,10 @@ describe('Format value with regex mappings', () => { const value = 'www.baz.com'; expect(getValueMappingResult(testSet2, value)).toBeNull(); }); + + it('should not replace match when replace text is null', () => { + expect(getValueMappingResult(testSet2, 'hello my name is')).toEqual({ color: 'red' }); + }); }); describe('isNumeric', () => { diff --git a/packages/grafana-data/src/utils/valueMappings.ts b/packages/grafana-data/src/utils/valueMappings.ts index e6828ba12a2dd..e774d0580162a 100644 --- a/packages/grafana-data/src/utils/valueMappings.ts +++ b/packages/grafana-data/src/utils/valueMappings.ts @@ -58,9 +58,13 @@ export function getValueMappingResult(valueMappings: ValueMapping[], value: any) const regex = stringToJsRegex(vm.options.pattern); if (value.match(regex)) { - const thisResult = Object.create(vm.options.result); - thisResult.text = value.replace(regex, vm.options.result.text || ''); - return thisResult; + const res = { ...vm.options.result }; + + if (res.text != null) { + res.text = value.replace(regex, vm.options.result.text || ''); + } + + return res; } case MappingType.SpecialValue: diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 8614f137871cf..710daefad61ec 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e-selectors", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana End-to-End Test Selectors Library", "keywords": [ "cli", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index be5d6660856e2..b4a3d1f75ed0b 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana End-to-End Test Library", "keywords": [ "cli", @@ -49,7 +49,7 @@ "@babel/core": "7.14.6", "@babel/preset-env": "7.14.7", "@cypress/webpack-preprocessor": "5.9.1", - "@grafana/e2e-selectors": "8.3.0-pre", + "@grafana/e2e-selectors": "8.3.10", "@grafana/tsconfig": "^1.0.0-rc1", "@mochajs/json-file-reporter": "^1.2.0", "babel-loader": "8.2.2", diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts index 9d3d57fa8f3d5..2d4b089c6b4cc 100644 --- a/packages/grafana-e2e/src/flows/addDashboard.ts +++ b/packages/grafana-e2e/src/flows/addDashboard.ts @@ -70,7 +70,7 @@ export const addDashboard = (config?: Partial) => { setDashboardTimeRange(timeRange); e2e.components.PageToolbar.item('Save dashboard').click(); - e2e.pages.SaveDashboardAsModal.newName().clear().type(title); + e2e.pages.SaveDashboardAsModal.newName().clear().type(title, { force: true }); e2e.pages.SaveDashboardAsModal.save().click(); e2e.flows.assertSuccessNotification(); diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index bec195852c0ee..5d6659ef48bb3 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/runtime", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana Runtime Library", "keywords": [ "grafana", @@ -23,9 +23,9 @@ }, "dependencies": { "@emotion/css": "11.1.3", - "@grafana/data": "8.3.0-pre", - "@grafana/e2e-selectors": "8.3.0-pre", - "@grafana/ui": "8.3.0-pre", + "@grafana/data": "8.3.10", + "@grafana/e2e-selectors": "8.3.10", + "@grafana/ui": "8.3.10", "@sentry/browser": "5.25.0", "history": "4.10.1", "lodash": "4.17.21", diff --git a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx new file mode 100644 index 0000000000000..25e577d6712d7 --- /dev/null +++ b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { PanelData } from '@grafana/data'; + +/** + * Describes the properties that can be passed to the PanelDataErrorView. + * + * @alpha + */ +export interface PanelDataErrorViewProps { + message?: string; + panelId: number; + data: PanelData; + needsTimeField?: boolean; + needsNumberField?: boolean; + needsStringField?: boolean; + // suggestions?: VisualizationSuggestion[]; <<< for sure optional +} + +/** + * Simplified type with defaults that describes the PanelDataErrorView. + * + * @internal + */ +export type PanelDataErrorViewType = React.ComponentType; + +/** + * PanelDataErrorView allows panels to show a consistent error message when + * the result structure does not meet expected criteria + * + * @alpha + */ +export let PanelDataErrorView: PanelDataErrorViewType = ({ message }) => { + return
Unable to render data: {message}.
; +}; + +/** + * Used to bootstrap the PanelDataErrorView during application start so the + * PanelDataErrorView is exposed via runtime. + * + * @internal + */ +export function setPanelDataErrorView(renderer: PanelDataErrorViewType) { + PanelDataErrorView = renderer; +} diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 6d30d2d1cf3e7..1c3f648a3fb4c 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -22,7 +22,8 @@ export { BackendDataSourceResponse, DataResponse, } from './utils/queryResponse'; +export { PanelRenderer, PanelRendererProps } from './components/PanelRenderer'; +export { PanelDataErrorView, PanelDataErrorViewProps } from './components/PanelDataErrorView'; export { toDataQueryError } from './utils/toDataQueryError'; -export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer'; export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner'; export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker'; diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index d56db7f27cb4f..31baf9a9f5ef0 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -24,7 +24,10 @@ export interface DataSourceSrv { /** * Get settings and plugin metadata by name or uid */ - getInstanceSettings(ref?: DataSourceRef | string | null): DataSourceInstanceSettings | undefined; + getInstanceSettings( + ref?: DataSourceRef | string | null, + scopedVars?: ScopedVars + ): DataSourceInstanceSettings | undefined; } /** @public */ diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index a9271e937a69f..844e2a7ee0c10 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -119,7 +119,7 @@ class DataSourceWithBackend< } if (q.datasource) { - const ds = getDataSourceSrv().getInstanceSettings(q.datasource); + const ds = getDataSourceSrv().getInstanceSettings(q.datasource, request.scopedVars); if (!ds) { throw new Error(`Unknown Datasource: ${JSON.stringify(q.datasource)}`); diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index c5c679c500840..dc307a2a63cd8 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/schema", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana Schema Library", "keywords": [ "typescript" diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index bbb06243b3680..4bcaf115336d5 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -15,6 +15,8 @@ yarn install yarn dev ``` +> **Note:** If running NPM 7+ the `npx` commands mentioned in this article may hang. The workaround is to use `npx --legacy-peer-deps `. + ## Update your plugin to use grafana-toolkit Follow the steps below to start using grafana-toolkit in your existing plugin. @@ -225,7 +227,7 @@ To start using Emotion, you first must add it to your plugin dependencies: Then, import `css` function from Emotion: ```ts -import { css } from 'emotion'; +import { css } from '@emotion/css'; ``` Now you are ready to implement your styles: diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile index 79aefec081e4d..e56641813ad92 100644 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.14.2 +FROM alpine:3.14.3 USER root diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh index 5a73529ecd05f..dfbbcf1fac386 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-alpine/scripts/deploy.sh @@ -18,8 +18,8 @@ apk add --no-cache curl 'nodejs=14.16.1-r1' npm yarn build-base openssh git-lfs # apk add --no-cache xvfb glib nss nspr gdk-pixbuf "gtk+3.0" pango atk cairo dbus-libs libxcomposite libxrender libxi libxtst libxrandr libxscrnsaver alsa-lib at-spi2-atk at-spi2-core cups-libs gcompat libc6-compat # Install Go -filename="go1.17.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "6bf89fc4f5ad763871cf7eac80a2d594492de7a818303283f1366a7f6a30372d" +filename="go1.17.8.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "980e65a863377e69fd9b67df9d8395fd8e93858e7a24c9f55803421e453f4f99" untar_file "/tmp/$filename" # Install golangci-lint diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh index cdd3d4ab44233..6fd4db822ae60 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh @@ -22,8 +22,8 @@ source "/etc/profile" npm i -g yarn # Install Go -filename="go1.17.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "6bf89fc4f5ad763871cf7eac80a2d594492de7a818303283f1366a7f6a30372d" +filename="go1.17.8.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "980e65a863377e69fd9b67df9d8395fd8e93858e7a24c9f55803421e453f4f99" untar_file "/tmp/$filename" # Install golangci-lint diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh index 38b944a78fd52..c9c13d956d413 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci/scripts/deploy.sh @@ -2,8 +2,8 @@ source "./deploy-common.sh" # Install Go -filename="go1.17.linux-amd64.tar.gz" -get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "6bf89fc4f5ad763871cf7eac80a2d594492de7a818303283f1366a7f6a30372d" +filename="go1.17.8.linux-amd64.tar.gz" +get_file "https://dl.google.com/go/$filename" "/tmp/$filename" "980e65a863377e69fd9b67df9d8395fd8e93858e7a24c9f55803421e453f4f99" untar_file "/tmp/$filename" # Install golangci-lint diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 4f8bb72fd4351..00bee54857ed4 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/toolkit", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana Toolkit", "keywords": [ "grafana", @@ -28,10 +28,10 @@ "dependencies": { "@babel/core": "7.13.14", "@babel/preset-env": "7.13.12", - "@grafana/data": "8.3.0-pre", + "@grafana/data": "8.3.10", "@grafana/eslint-config": "2.5.1", "@grafana/tsconfig": "^1.0.0-rc1", - "@grafana/ui": "8.3.0-pre", + "@grafana/ui": "8.3.10", "@jest/core": "26.6.3", "@rushstack/eslint-patch": "1.0.6", "@types/command-exists": "^1.2.0", @@ -86,14 +86,14 @@ "postcss-loader": "3.0.0", "postcss-preset-env": "6.7.0", "prettier": "^2.2.1", - "react-dev-utils": "^11.0.4", + "react-dev-utils": "12.0.0", "replace-in-file": "^4.1.0", "replace-in-file-webpack-plugin": "^1.0.6", "rimraf": "^3.0.0", "sass": "1.27.0", "sass-loader": "8.0.2", "semver": "^7.1.3", - "simple-git": "^2.46.0", + "simple-git": "3.5.0", "style-loader": "1.1.3", "terser-webpack-plugin": "2.3.7", "ts-jest": "26.4.4", diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index 2ff2f2f01e47b..797684d4d9d7a 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -200,7 +200,21 @@ export const run = (includeInternalScripts = false) => { program .command('plugin:sign') .option('--signatureType ', 'Signature Type') - .option('--rootUrls ', 'Root URLs') + .option( + '--rootUrls ', + 'Root URLs', + function (url: string, urls: string[]) { + if (typeof url !== 'string') { + return urls; + } + + const parts = url.split(','); + urls.push(...parts); + + return urls; + }, + [] + ) .description('Create a plugin signature') .action(async (cmd) => { await execTask(pluginSignTask)({ diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts index 0eb2647686a7c..2b9a400df7155 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts @@ -11,6 +11,7 @@ import { promptPluginType, removeGitFiles, verifyGitExists, + removeLockFile, } from './plugin/create'; import { Task, TaskRunner } from './task'; @@ -40,10 +41,14 @@ const pluginCreateRunner: TaskRunner = async ({ name }) => // 5. Update json files (package.json, src/plugin.json) await prepareJsonFiles({ type: type, pluginDetails, pluginPath: destPath }); - // 6. Remove cloned repository .git dir + // 6. Starter templates include `yarn.lock` files which will rarely (if ever) be in sync with `latest` dist-tag + // so best to remove it after cloning. + removeLockFile({ pluginPath: destPath }); + + // 7. Remove cloned repository .git dir await removeGitFiles(destPath); - // 7. Promote Grafana Tutorials :) + // 8. Promote Grafana Tutorials :) printGrafanaTutorialsDetails(type); }; diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts index 62873703fc31e..f4f9143944c57 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import commandExists from 'command-exists'; -import { promises as fs, readFileSync } from 'fs'; +import { promises as fs, readFileSync, existsSync, unlinkSync } from 'fs'; import { prompt } from 'inquirer'; import { kebabCase } from 'lodash'; import path from 'path'; @@ -177,6 +177,7 @@ export const printGrafanaTutorialsDetails = (type: PluginType) => { console.group(); console.log(); console.log(chalk.bold.yellow(`Congrats! You have just created ${PluginNames[type]}.`)); + console.log('Please run `yarn install` to install frontend dependencies.'); console.log(); if (type !== 'backend-datasource-plugin') { console.log(`${PluginNames[type]} tutorial: ${TutorialPaths[type]}`); @@ -188,3 +189,10 @@ export const printGrafanaTutorialsDetails = (type: PluginType) => { console.groupEnd(); }; /* eslint-enable no-console */ + +export const removeLockFile = ({ pluginPath }: { pluginPath: string }) => { + const lockFilePath = path.resolve(pluginPath, 'yarn.lock'); + if (existsSync(lockFilePath)) { + unlinkSync(lockFilePath); + } +}; diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index cf9c86ffcdf9e..9e6e323814cc3 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -171,7 +171,6 @@ const getBaseWebpackConfig: WebpackConfigurationGetter = async (options) => { performance: { hints: false }, externals: [ - 'tslib', 'lodash', 'jquery', 'moment', diff --git a/packages/grafana-ui/package-lock.json b/packages/grafana-ui/package-lock.json new file mode 100644 index 0000000000000..8afd2fee1bc5f --- /dev/null +++ b/packages/grafana-ui/package-lock.json @@ -0,0 +1,21483 @@ +{ + "name": "@grafana/ui", + "version": "8.3.5", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@ampproject/remapping": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz", + "integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.0" + } + }, + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/compat-data": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.7.tgz", + "integrity": "sha512-p8pdE6j0a29TNGebNm7NzYZWB3xVZJBZ7XGs42uAKzQo8VQ3F0By/cQCtUEABwIqw5zo6WA4NbmxsfzADzMKnQ==", + "dev": true + }, + "@babel/generator": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.9.tgz", + "integrity": "sha512-rAdDousTwxbIxbz5I7GEQ3lUip+xVCXooZNbsydCWs3xA7ZsYOv+CFRdzGxRX78BmQHu9B1Eso59AOZQOJDEdQ==", + "dev": true, + "requires": { + "@babel/types": "^7.17.0", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.16.7.tgz", + "integrity": "sha512-C6FdbRaxYjwVu/geKW4ZeQ0Q31AftgRcdSnZ5/jsH6BzCJbtvXvhpfkbkThYSuutZA7nCXpPR6AD9zd1dprMkA==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.7.tgz", + "integrity": "sha512-UFzlz2jjd8kroj0hmCFV5zr+tQPi1dpC2cRsDV/3IEW8bJfCPrPpmcSN6ZS8RqIq4LXcmpipCQFPddyFA5Yc7w==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.17.7", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.17.5", + "semver": "^6.3.0" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.9.tgz", + "integrity": "sha512-kUjip3gruz6AJKOq5i3nC6CoCEEF/oHH3cp6tOZhB+IyyyPyW0g1Gfsxn3mkk6S08pIA2y8GQh609v9G/5sHVQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-member-expression-to-functions": "^7.17.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.17.0.tgz", + "integrity": "sha512-awO2So99wG6KnlE+TPs6rn83gCz5WlEePJDTnLEqbchMVrBeAujURVphRdigsk094VhvZehFoNOihSlcBjwsXA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "regexpu-core": "^5.0.1" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.1.tgz", + "integrity": "sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz", + "integrity": "sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "dev": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.17.7.tgz", + "integrity": "sha512-thxXgnQ8qQ11W2wVUObIqDL4p148VMxkt5T/qpN5k2fboRyzFGFmKsTGViquyM5QHKUy48OZoca8kw4ajaDPyw==", + "dev": true, + "requires": { + "@babel/types": "^7.17.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz", + "integrity": "sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==" + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz", + "integrity": "sha512-fm0gH7Flb8H51LqJHy3HJ3wnE1+qtYR2A99K06ahwrawLdOFsCEWjZOrYricXJHoPSudNKxrMBUPEIPxiIIvBw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-wrap-function": "^7.16.8", + "@babel/types": "^7.16.8" + } + }, + "@babel/helper-replace-supers": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz", + "integrity": "sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-member-expression-to-functions": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/traverse": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "dev": true, + "requires": { + "@babel/types": "^7.17.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.16.0.tgz", + "integrity": "sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==" + }, + "@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.16.8.tgz", + "integrity": "sha512-8RpyRVIAW1RcDDGTA+GpPAwV22wXCfKOoM9bet6TLkGIFTkRQSkH1nMQ5Yet4MpoXe1ZwHPVtNasc2w0uZMqnw==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.8", + "@babel/types": "^7.16.8" + } + }, + "@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "dev": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/highlight": { + "version": "7.16.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", + "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.9.tgz", + "integrity": "sha512-vqUSBLP8dQHFPdPi9bc5GK9vRkYHJ49fsZdtoJ8EQ8ibpwk5rPKfvNIwChB0KVXcIjcepEBBd2VHC5r9Gy8ueg==", + "dev": true + }, + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.16.7.tgz", + "integrity": "sha512-anv/DObl7waiGEnC24O9zqL0pSuI9hljihqiDuFHC8d7/bjr/4RLGPWuc8rYOff/QPzbEPSkzG8wGG9aDuhHRg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.16.7.tgz", + "integrity": "sha512-di8vUHRdf+4aJ7ltXhaDbPoszdkh59AQtJM5soLsuHpQJdFQZOA4uGj0V2u/CZ8bJ/u8ULDL5yq6FO/bCXnKHw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.7" + } + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.16.8.tgz", + "integrity": "sha512-71YHIvMuiuqWJQkebWJtdhQTfd4Q4mF76q2IX37uZPkG9+olBxsX+rH1vkhFto4UeJZ9dPY2s+mDvhDm1u2BGQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-remap-async-to-generator": "^7.16.8", + "@babel/plugin-syntax-async-generators": "^7.8.4" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz", + "integrity": "sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-proposal-class-static-block": { + "version": "7.17.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.17.6.tgz", + "integrity": "sha512-X/tididvL2zbs7jZCeeRJ8167U/+Ac135AM6jCAx6gYXDUviZV5Ku9UDvWS2NCuWlFjIRXklYhwo6HhAC7ETnA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.17.6", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + } + }, + "@babel/plugin-proposal-decorators": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.17.9.tgz", + "integrity": "sha512-EfH2LZ/vPa2wuPwJ26j+kYRkaubf89UlwxKXtxqEm57HrgSEYDB8t4swFP+p8LcI9yiP9ZRJJjo/58hS6BnaDA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.17.9", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/plugin-syntax-decorators": "^7.17.0", + "charcodes": "^0.2.0" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz", + "integrity": "sha512-I8SW9Ho3/8DRSdmDdH3gORdyUuYnk1m4cMxUAdu5oy4n3OfN8flDEH+d60iG7dUfi0KkYwSvoalHzzdRzpWHTg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + } + }, + "@babel/plugin-proposal-export-default-from": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.16.7.tgz", + "integrity": "sha512-+cENpW1rgIjExn+o5c8Jw/4BuH4eGKKYvkMB8/0ZxFQ9mC0t4z09VsPIwNg6waF69QYC81zxGeAsREGuqQoKeg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-export-default-from": "^7.16.7" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.16.7.tgz", + "integrity": "sha512-ZxdtqDXLRGBL64ocZcs7ovt71L3jhC1RGSyR996svrCi3PYqHNkb3SwPJCs8RIzD86s+WPpt2S73+EHCGO+NUA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.16.7.tgz", + "integrity": "sha512-lNZ3EEggsGY78JavgbHsK9u5P3pQaW7k4axlgFLYkMd7UBsiNahCITShLjNQschPyjtO6dADrL24757IdhBrsQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-json-strings": "^7.8.3" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.16.7.tgz", + "integrity": "sha512-K3XzyZJGQCr00+EtYtrDjmwX7o7PLK6U9bi1nCwkQioRFVUv6dJoxbQjtWVtP+bCPy82bONBKG8NPyQ4+i6yjg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.16.7.tgz", + "integrity": "sha512-aUOrYU3EVtjf62jQrCj63pYZ7k6vns2h/DQvHPWGmsJRYzWXZ6/AsfgpiRy6XiuIDADhJzP2Q9MwSMKauBQ+UQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.16.7.tgz", + "integrity": "sha512-vQgPMknOIgiuVqbokToyXbkY/OmmjAzr/0lhSIbG/KmnzXPGwW/AdhdKpi+O4X/VkWiWjnkKOBiqJrTaC98VKw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.17.3.tgz", + "integrity": "sha512-yuL5iQA/TbZn+RGAfxQXfi7CNLmKi1f8zInn4IgobuCWcAb7i+zj4TYzQ9l8cEzVyJ89PDGuqxK1xZpUDISesw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.17.0", + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.16.7" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.16.7.tgz", + "integrity": "sha512-eMOH/L4OvWSZAE1VkHbr1vckLG1WUcHGJSLqqQwl2GaUqG6QjddvrOaTUMNYiv77H5IKPMZ9U9P7EaHwvAShfA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.16.7.tgz", + "integrity": "sha512-eC3xy+ZrUcBtP7x+sq62Q/HYd674pPTb/77XZMb5wbDPGWIdUbSr4Agr052+zaUPSb+gGRnjxXfKFvx5iMJ+DA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.16.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.16.11.tgz", + "integrity": "sha512-F/2uAkPlXDr8+BHpZvo19w3hLFKge+k75XUprE6jaqKxjGkSYcK+4c+bup5PdW/7W/Rpjwql7FTVEDW+fRAQsw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.10", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-proposal-private-property-in-object": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.16.7.tgz", + "integrity": "sha512-rMQkjcOFbm+ufe3bTZLyOfsOUOxyvLXZJCTARhJr+8UMSoZmqTe1K1BgkFcrW37rAchWg57yI69ORxiWvUINuQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.16.7.tgz", + "integrity": "sha512-QRK0YI/40VLhNVGIjRNAAQkEHws0cswSdFFjpFyt943YmJIU1da9uW63Iu6NFV6CxTZW5eTDCrwZUstBWgp/Rg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-decorators": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.0.tgz", + "integrity": "sha512-qWe85yCXsvDEluNP0OyeQjH63DlhAR3W7K9BxxU1MvbDb48tgBG+Ao6IJJ6smPDrrVzSQZrbF6donpkFBMcs3A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-default-from": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.16.7.tgz", + "integrity": "sha512-4C3E4NsrLOgftKaTYTULhHsuQrGv3FHrBzOMDiS7UYKIpgGBkAdawg4h+EI8zPeK9M0fiIIh72hIwsI24K7MbA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-flow": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.16.7.tgz", + "integrity": "sha512-UDo3YGQO0jH6ytzVwgSLv9i/CzMcUjbKenL67dTrAZPPv6GFAtDhe6jqnvmoKzC/7htNTohhos+onPtDMqJwaQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz", + "integrity": "sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.7.tgz", + "integrity": "sha512-9ffkFFMbvzTvv+7dTp/66xvZAWASuPD5Tl9LK3Z9vhOmANo6j94rik+5YMBt4CwHVMWLWpMsriIc2zsa3WW3xQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.16.8.tgz", + "integrity": "sha512-MtmUmTJQHCnyJVrScNzNlofQJ3dLFuobYn3mwOTKHnSCMtbNsqvF71GQmJfFjdrXSsAA7iysFmYWw4bXZ20hOg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-remap-async-to-generator": "^7.16.8" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.16.7.tgz", + "integrity": "sha512-JUuzlzmF40Z9cXyytcbZEZKckgrQzChbQJw/5PuEHYeqzCsvebDx0K0jWnIIVcmmDOAVctCgnYs0pMcrYj2zJg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.16.7.tgz", + "integrity": "sha512-ObZev2nxVAYA4bhyusELdo9hb3H+A56bxH3FZMbEImZFiEDYVHXQSJ1hQKFlDnlt8G9bBrCZ5ZpURZUrV4G5qQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.16.7.tgz", + "integrity": "sha512-WY7og38SFAGYRe64BrjKf8OrE6ulEHtr5jEYaZMwox9KebgqPi67Zqz8K53EKk1fFEJgm96r32rkKZ3qA2nCWQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-optimise-call-expression": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.16.7.tgz", + "integrity": "sha512-gN72G9bcmenVILj//sv1zLNaPyYcOzUho2lIJBMh/iakJ9ygCo/hEF9cpGb61SCMEDxbbyBoVQxrt+bWKu5KGw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.17.7.tgz", + "integrity": "sha512-XVh0r5yq9sLR4vZ6eVZe8FKfIcSgaTBxVBRSYokRj2qksf6QerYnTxz9/GTuKTH/n/HwLP7t6gtlybHetJ/6hQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.16.7.tgz", + "integrity": "sha512-Lyttaao2SjZF6Pf4vk1dVKv8YypMpomAbygW+mU5cYP3S5cWTfCJjG8xV6CFdzGFlfWK81IjL9viiTvpb6G7gQ==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.16.7.tgz", + "integrity": "sha512-03DvpbRfvWIXyK0/6QiR1KMTWeT6OcQ7tbhjrXyFS02kjuX/mu5Bvnh5SDSWHxyawit2g5aWhKwI86EE7GUnTw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.16.7.tgz", + "integrity": "sha512-8UYLSlyLgRixQvlYH3J2ekXFHDFLQutdy7FfFAMm3CPZ6q9wHCwnUyiXpQCe3gVVnQlHc5nsuiEVziteRNTXEA==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.16.7.tgz", + "integrity": "sha512-mzmCq3cNsDpZZu9FADYYyfZJIOrSONmHcop2XEKPdBNMa4PDC4eEvcOvzZaCNcjKu72v0XQlA5y1g58aLRXdYg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-flow": "^7.16.7" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.16.7.tgz", + "integrity": "sha512-/QZm9W92Ptpw7sjI9Nx1mbcsWz33+l8kuMIQnDwgQBG5s3fAfQvkRjQ7NqXhtNcKOnPkdICmUHyCaWW06HCsqg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.16.7.tgz", + "integrity": "sha512-SU/C68YVwTRxqWj5kgsbKINakGag0KTgq9f2iZEXdStoAbOzLHEBRYzImmA6yFo8YZhJVflvXmIHUO7GWHmxxA==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-function-name": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.16.7.tgz", + "integrity": "sha512-6tH8RTpTWI0s2sV6uq3e/C9wPo4PTqqZps4uF0kzQ9/xPLFQtipynvmT1g/dOfEJ+0EQsHhkQ/zyRId8J2b8zQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.16.7.tgz", + "integrity": "sha512-mBruRMbktKQwbxaJof32LT9KLy2f3gH+27a5XSuXo6h7R3vqltl0PgZ80C8ZMKw98Bf8bqt6BEVi3svOh2PzMw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.16.7.tgz", + "integrity": "sha512-KaaEtgBL7FKYwjJ/teH63oAmE3lP34N3kshz8mm4VMAw7U3PxjVwwUmxEFksbgsNUaO3wId9R2AVQYSEGRa2+g==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.17.9.tgz", + "integrity": "sha512-2TBFd/r2I6VlYn0YRTz2JdazS+FoUuQ2rIFHoAxtyP/0G3D82SBLaRq9rnUkpqlLg03Byfl/+M32mpxjO6KaPw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.17.8.tgz", + "integrity": "sha512-39reIkMTUVagzgA5x88zDYXPCMT6lcaRKs1+S9K6NKBPErbgO/w/kP8GlNQTC87b412ZTlmNgr3k2JrWgHH+Bw==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.16.7.tgz", + "integrity": "sha512-EMh7uolsC8O4xhudF2F6wedbSHm1HHZ0C6aJ7K67zcDNidMzVcxWdGr+htW9n21klm+bOn+Rx4CBsAntZd3rEQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.16.8.tgz", + "integrity": "sha512-j3Jw+n5PvpmhRR+mrgIh04puSANCk/T/UA3m3P1MjJkhlK906+ApHhDIqBQDdOgL/r1UYpz4GNclTXxyZrYGSw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.16.7.tgz", + "integrity": "sha512-xiLDzWNMfKoGOpc6t3U+etCE2yRnn3SM09BXqWPIZOBpL2gvVrBWUKnsJx0K/ADi5F5YC5f8APFfWrz25TdlGg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.16.7.tgz", + "integrity": "sha512-14J1feiQVWaGvRxj2WjyMuXS2jsBkgB3MdSN5HuC2G5nRspa5RK9COcs82Pwy5BuGcjb+fYaUj94mYcOj7rCvw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-replace-supers": "^7.16.7" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.16.7.tgz", + "integrity": "sha512-AT3MufQ7zZEhU2hwOA11axBnExW0Lszu4RL/tAlUJBuNoRak+wehQW8h6KcXOcgjY42fHtDxswuMhMjFEuv/aw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.16.7.tgz", + "integrity": "sha512-z4FGr9NMGdoIl1RqavCqGG+ZuYjfZ/hkCIeuH6Do7tXmSm0ls11nYVSJqFEUOSJbDab5wC6lRE/w6YjVcr6Hqw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz", + "integrity": "sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.16.7.tgz", + "integrity": "sha512-hs71ToC97k3QWxswh2ElzMFABXHvGiJ01IB1TbYQDGeWRKWz/MPUTh5jGExdHvosYKpnJW5Pm3S4+TA3FyX+GA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.17.9.tgz", + "integrity": "sha512-Lc2TfbxR1HOyn/c6b4Y/b6NHoTb67n/IoWLxTu4kC7h4KQnWlhCq2S8Tx0t2SVvv5Uu87Hs+6JEJ5kt2tYGylQ==", + "dev": true, + "requires": { + "regenerator-transform": "^0.15.0" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.16.7.tgz", + "integrity": "sha512-KQzzDnZ9hWQBjwi5lpY5v9shmm6IVG0U9pB18zvMu2i4H90xpT4gmqwPYsn8rObiadYe2M0gmgsiOIF5A/2rtg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.16.7.tgz", + "integrity": "sha512-hah2+FEnoRoATdIb05IOXf+4GzXYTq75TVhIn1PewihbpyrNWUt2JbudKQOETWw6QpLe+AIUpJ5MVLYTQbeeUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.16.7.tgz", + "integrity": "sha512-+pjJpgAngb53L0iaA5gU/1MLXJIfXcYepLgXB3esVRf4fqmj8f2cxM3/FKaHsZms08hFQJkFccEWuIpm429TXg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.16.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.16.7.tgz", + "integrity": "sha512-NJa0Bd/87QV5NZZzTuZG5BPJjLYadeSZ9fO6oOUoL4iQx+9EEuw/eEM92SrsT19Yc2jgB1u1hsjqDtH02c3Drw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.16.7.tgz", + "integrity": "sha512-VwbkDDUeenlIjmfNeDX/V0aWrQH2QiVyJtwymVQSzItFDTpxfyJh3EVaQiS0rIN/CqbLGr0VcGmuwyTdZtdIsA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.16.7.tgz", + "integrity": "sha512-p2rOixCKRJzpg9JB4gjnG4gjWkWa89ZoYUnl9snJ1cWIcTH/hvxZqfO+WjG6T8DRBpctEol5jw1O5rA8gkCokQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-typescript": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", + "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-typescript": "^7.16.7" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", + "integrity": "sha512-TAV5IGahIz3yZ9/Hfv35TV2xEm+kaBDaZQCn2S/hG9/CZ0DktxJv9eKfPc7yYCvOYR4JGx1h8C+jcSOvgaaI/Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.16.7.tgz", + "integrity": "sha512-oC5tYYKw56HO75KZVLQ+R/Nl3Hro9kf8iG0hXoaHP7tjAyCpvqBiSNe6vGrZni1Z6MggmUOC6A7VP7AVmw225Q==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/preset-env": { + "version": "7.16.11", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.16.11.tgz", + "integrity": "sha512-qcmWG8R7ZW6WBRPZK//y+E3Cli151B20W1Rv7ln27vuPaXU/8TKms6jFdiJtF7UDTxcrb7mZd88tAeK9LjdT8g==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.16.8", + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.16.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.16.7", + "@babel/plugin-proposal-async-generator-functions": "^7.16.8", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-proposal-class-static-block": "^7.16.7", + "@babel/plugin-proposal-dynamic-import": "^7.16.7", + "@babel/plugin-proposal-export-namespace-from": "^7.16.7", + "@babel/plugin-proposal-json-strings": "^7.16.7", + "@babel/plugin-proposal-logical-assignment-operators": "^7.16.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", + "@babel/plugin-proposal-numeric-separator": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.16.7", + "@babel/plugin-proposal-optional-catch-binding": "^7.16.7", + "@babel/plugin-proposal-optional-chaining": "^7.16.7", + "@babel/plugin-proposal-private-methods": "^7.16.11", + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "@babel/plugin-proposal-unicode-property-regex": "^7.16.7", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.16.7", + "@babel/plugin-transform-async-to-generator": "^7.16.8", + "@babel/plugin-transform-block-scoped-functions": "^7.16.7", + "@babel/plugin-transform-block-scoping": "^7.16.7", + "@babel/plugin-transform-classes": "^7.16.7", + "@babel/plugin-transform-computed-properties": "^7.16.7", + "@babel/plugin-transform-destructuring": "^7.16.7", + "@babel/plugin-transform-dotall-regex": "^7.16.7", + "@babel/plugin-transform-duplicate-keys": "^7.16.7", + "@babel/plugin-transform-exponentiation-operator": "^7.16.7", + "@babel/plugin-transform-for-of": "^7.16.7", + "@babel/plugin-transform-function-name": "^7.16.7", + "@babel/plugin-transform-literals": "^7.16.7", + "@babel/plugin-transform-member-expression-literals": "^7.16.7", + "@babel/plugin-transform-modules-amd": "^7.16.7", + "@babel/plugin-transform-modules-commonjs": "^7.16.8", + "@babel/plugin-transform-modules-systemjs": "^7.16.7", + "@babel/plugin-transform-modules-umd": "^7.16.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.16.8", + "@babel/plugin-transform-new-target": "^7.16.7", + "@babel/plugin-transform-object-super": "^7.16.7", + "@babel/plugin-transform-parameters": "^7.16.7", + "@babel/plugin-transform-property-literals": "^7.16.7", + "@babel/plugin-transform-regenerator": "^7.16.7", + "@babel/plugin-transform-reserved-words": "^7.16.7", + "@babel/plugin-transform-shorthand-properties": "^7.16.7", + "@babel/plugin-transform-spread": "^7.16.7", + "@babel/plugin-transform-sticky-regex": "^7.16.7", + "@babel/plugin-transform-template-literals": "^7.16.7", + "@babel/plugin-transform-typeof-symbol": "^7.16.7", + "@babel/plugin-transform-unicode-escapes": "^7.16.7", + "@babel/plugin-transform-unicode-regex": "^7.16.7", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.16.8", + "babel-plugin-polyfill-corejs2": "^0.3.0", + "babel-plugin-polyfill-corejs3": "^0.5.0", + "babel-plugin-polyfill-regenerator": "^0.3.0", + "core-js-compat": "^3.20.2", + "semver": "^6.3.0" + } + }, + "@babel/preset-flow": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.16.7.tgz", + "integrity": "sha512-6ceP7IyZdUYQ3wUVqyRSQXztd1YmFHWI4Xv11MIqAlE4WqxBSd/FZ61V9k+TS5Gd4mkHOtQtPp9ymRpxH4y1Ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-flow-strip-types": "^7.16.7" + } + }, + "@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/preset-react": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.16.7.tgz", + "integrity": "sha512-fWpyI8UM/HE6DfPBzD8LnhQ/OcH8AgTaqcqP2nGOXEUV+VKBR5JRN9hCk9ai+zQQ57vtm9oWeXguBCPNUjytgA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-react-display-name": "^7.16.7", + "@babel/plugin-transform-react-jsx": "^7.16.7", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-pure-annotations": "^7.16.7" + } + }, + "@babel/preset-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", + "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-typescript": "^7.16.7" + } + }, + "@babel/register": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.17.7.tgz", + "integrity": "sha512-fg56SwvXRifootQEDQAu1mKdjh5uthPzdO0N6t358FktfL4XjAVXuH58ULoiW8mesxiOgNIrxiImqEwv0+hRRA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.5", + "source-map-support": "^0.5.16" + } + }, + "@babel/runtime": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz", + "integrity": "sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/traverse": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.9.tgz", + "integrity": "sha512-PQO8sDIJ8SIwipTPiR71kJQCKQYB5NGImbOviK8K+kg5xkNSYXLBupuX9QhatFowrsvo9Hj8WgArg3W7ijNAQw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.9", + "@babel/types": "^7.17.0", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/types": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.16.8.tgz", + "integrity": "sha512-smN2DQc5s4M7fntyjGtyIPbRJv6wW4rU/94fmYJ7PKQuZkC0qGMHXJbg6sNGt12JmVr4k5YaptI/XtiLJBnmIg==", + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@base2/pretty-print-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz", + "integrity": "sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==", + "dev": true + }, + "@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "@cnakazawa/watch": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", + "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==", + "dev": true, + "requires": { + "exec-sh": "^0.3.2", + "minimist": "^1.2.0" + } + }, + "@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true + }, + "@emotion/babel-plugin": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.7.2.tgz", + "integrity": "sha512-6mGSCWi9UzXut/ZAN6lGFu33wGR3SJisNl3c0tvlmb8XChH1b2SUvxvnOh7hvLpqyRdHHU9AiazV3Cwbk5SXKQ==", + "requires": { + "@babel/helper-module-imports": "^7.12.13", + "@babel/plugin-syntax-jsx": "^7.12.13", + "@babel/runtime": "^7.13.10", + "@emotion/hash": "^0.8.0", + "@emotion/memoize": "^0.7.5", + "@emotion/serialize": "^1.0.2", + "babel-plugin-macros": "^2.6.1", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.0.13" + } + }, + "@emotion/cache": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.7.1.tgz", + "integrity": "sha512-r65Zy4Iljb8oyjtLeCuBH8Qjiy107dOYC6SJq7g7GV5UCQWMObY4SJDPGFjiiVpPrOJ2hmJOoBiYTC7hwx9E2A==", + "requires": { + "@emotion/memoize": "^0.7.4", + "@emotion/sheet": "^1.1.0", + "@emotion/utils": "^1.0.0", + "@emotion/weak-memoize": "^0.2.5", + "stylis": "4.0.13" + } + }, + "@emotion/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.3.1.tgz", + "integrity": "sha512-447aUEjPIm0MnE6QYIaFz9VQOHSXf4Iu6EWOIqq11EAPqinkSZmfymPTmlOE3QjLv846lH4JVZBUOtwGbuQoww==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/cache": "^10.0.27", + "@emotion/css": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + }, + "dependencies": { + "@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "dev": true, + "requires": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, + "@emotion/css": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", + "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", + "dev": true, + "requires": { + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3", + "babel-plugin-emotion": "^10.0.27" + } + }, + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "dev": true + }, + "@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "dev": true, + "requires": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "@emotion/sheet": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", + "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==", + "dev": true + }, + "@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "dev": true + }, + "csstype": { + "version": "2.6.20", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", + "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==", + "dev": true + } + } + }, + "@emotion/css": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.1.3.tgz", + "integrity": "sha512-RSQP59qtCNTf5NWD6xM08xsQdCZmVYnX/panPYvB6LQAPKQB6GL49Njf0EMbS3CyDtrlWsBcmqBtysFvfWT3rA==", + "requires": { + "@emotion/babel-plugin": "^11.0.0", + "@emotion/cache": "^11.1.3", + "@emotion/serialize": "^1.0.0", + "@emotion/sheet": "^1.0.0", + "@emotion/utils": "^1.0.0" + } + }, + "@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "dev": true, + "requires": { + "@emotion/memoize": "0.7.4" + }, + "dependencies": { + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "dev": true + } + } + }, + "@emotion/memoize": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz", + "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==" + }, + "@emotion/react": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.1.5.tgz", + "integrity": "sha512-xfnZ9NJEv9SU9K2sxXM06lzjK245xSeHRpUh67eARBm3PBHjjKIZlfWZ7UQvD0Obvw6ZKjlC79uHrlzFYpOB/Q==", + "requires": { + "@babel/runtime": "^7.7.2", + "@emotion/cache": "^11.1.3", + "@emotion/serialize": "^1.0.0", + "@emotion/sheet": "^1.0.1", + "@emotion/utils": "^1.0.0", + "@emotion/weak-memoize": "^0.2.5", + "hoist-non-react-statics": "^3.3.1" + } + }, + "@emotion/serialize": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.2.tgz", + "integrity": "sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A==", + "requires": { + "@emotion/hash": "^0.8.0", + "@emotion/memoize": "^0.7.4", + "@emotion/unitless": "^0.7.5", + "@emotion/utils": "^1.0.0", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + } + } + }, + "@emotion/sheet": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.0.tgz", + "integrity": "sha512-u0AX4aSo25sMAygCuQTzS+HsImZFuS8llY8O7b9MDRzbJM0kVJlAz6KNDqcG7pOuQZJmj/8X/rAW+66kMnMW+g==" + }, + "@emotion/styled": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-10.3.0.tgz", + "integrity": "sha512-GgcUpXBBEU5ido+/p/mCT2/Xx+Oqmp9JzQRuC+a4lYM4i4LBBn/dWvc0rQ19N9ObA8/T4NWMrPNe79kMBDJqoQ==", + "dev": true, + "requires": { + "@emotion/styled-base": "^10.3.0", + "babel-plugin-emotion": "^10.0.27" + } + }, + "@emotion/styled-base": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@emotion/styled-base/-/styled-base-10.3.0.tgz", + "integrity": "sha512-PBRqsVKR7QRNkmfH78hTSSwHWcwDpecH9W6heujWAcyp2wdz/64PP73s7fWS1dIPm8/Exc8JAzYS8dEWXjv60w==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/is-prop-valid": "0.8.8", + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3" + }, + "dependencies": { + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "dev": true + }, + "@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "dev": true, + "requires": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "dev": true + }, + "csstype": { + "version": "2.6.20", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", + "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==", + "dev": true + } + } + }, + "@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", + "dev": true + }, + "@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "@emotion/utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.0.0.tgz", + "integrity": "sha512-mQC2b3XLDs6QCW+pDQDiyO/EdGZYOygE8s5N5rrzjSI4M3IejPE/JPndCBwRT9z982aqQNi6beWs1UeayrQxxA==" + }, + "@emotion/weak-memoize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", + "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" + }, + "@formatjs/ecma402-abstract": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.1.tgz", + "integrity": "sha512-tgtNODZUGuUI6PAcnvaLZpGrZLVkXnnAvgzOiueYMzFdOdcOw4iH1WKhCe3+r6VR8rHKToJ2HksUGNCB+zt/bg==", + "requires": { + "@formatjs/intl-localematcher": "0.2.22", + "tslib": "^2.1.0" + } + }, + "@formatjs/fast-memoize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", + "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "requires": { + "tslib": "^2.1.0" + } + }, + "@formatjs/icu-messageformat-parser": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.16.tgz", + "integrity": "sha512-sYg0ImXsAqBbjU/LotoCD9yKC5nUpWVy3s4DwWerHXD4sm62FcjMF8mekwudRk3eZLHqSO+M21MpFUUjDQ+Q5Q==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.1", + "@formatjs/icu-skeleton-parser": "1.3.3", + "tslib": "^2.1.0" + } + }, + "@formatjs/icu-skeleton-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.3.tgz", + "integrity": "sha512-ifWnzjmHPHUF89UpCvClTP66sXYFc8W/qg7Qt+qtTUB9BqRWlFeUsevAzaMYDJsRiOy4S2WJFrJoZgRKUFfPGQ==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.1", + "tslib": "^2.1.0" + } + }, + "@formatjs/intl-localematcher": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.22.tgz", + "integrity": "sha512-z+TvbHW8Q/g2l7/PnfUl0mV9gWxV4d0HT6GQyzkO5QI6QjCvCZGiztnmLX7zoyS16uSMvZ2PoMDfSK9xvZkRRA==", + "requires": { + "tslib": "^2.1.0" + } + }, + "@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true + }, + "@grafana/aws-sdk": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@grafana/aws-sdk/-/aws-sdk-0.0.3.tgz", + "integrity": "sha512-V0PJLk+oU1C33knurXp8BkT5N2ctDu9b21zpK16vkJAs5aOiH/OaOhQ/IP9ipxYhB+b9PFjr8RXagV/8XJ8xwg==" + }, + "@grafana/slate-react": { + "version": "0.22.10-grafana", + "resolved": "https://registry.npmjs.org/@grafana/slate-react/-/slate-react-0.22.10-grafana.tgz", + "integrity": "sha512-Dl/Nfft7i6enS1ToWUInv7V3L5UhhtpIdRln+967f5roj+cI+enqOhpP0HY+4rAp6XW0qUVRujqc30KJKID3jg==", + "requires": { + "debug": "^3.1.0", + "get-window": "^1.1.1", + "is-window": "^1.0.2", + "lodash": "^4.1.1", + "memoize-one": "^4.0.0", + "prop-types": "^15.5.8", + "react-immutable-proptypes": "^2.1.0", + "selection-is-backward": "^1.0.0", + "slate-base64-serializer": "^0.2.111", + "slate-dev-environment": "^0.2.2", + "slate-hotkeys": "^0.2.9", + "slate-plain-serializer": "^0.7.10", + "slate-prop-types": "^0.5.41", + "slate-react-placeholder": "^0.2.8", + "tiny-invariant": "^1.0.1", + "tiny-warning": "^0.0.3" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "memoize-one": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.3.tgz", + "integrity": "sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==" + } + } + }, + "@grafana/tsconfig": { + "version": "1.0.0-rc1", + "resolved": "https://registry.npmjs.org/@grafana/tsconfig/-/tsconfig-1.0.0-rc1.tgz", + "integrity": "sha512-nucKPGyzlSKYSiJk5RA8GzMdVWhdYNdF+Hh65AXxjD9PlY69JKr5wANj8bVdQboag6dgg0BFKqgKPyY+YtV4Iw==", + "dev": true + }, + "@internationalized/date": { + "version": "3.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.0.0-alpha.1.tgz", + "integrity": "sha512-fxciU4AQ/4XBYfse/mT9h1nsyNkmQkxwQtTmQVu6b4Tp2u95Y3m5BNgWgV2m3vLiiKZ82NtHJXAIGoqiK53w4g==", + "requires": { + "@babel/runtime": "^7.6.2" + } + }, + "@internationalized/message": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.0.3.tgz", + "integrity": "sha512-TpNLP6FgzD9kukdNOhcxYhULf1mcE7Du+eMZe5voSP/yWlAl9GsJqPVY2knsR5Ld1oQELhxU61griqn6uhGsnA==", + "requires": { + "@babel/runtime": "^7.6.2", + "intl-messageformat": "^9.6.12" + } + }, + "@internationalized/number": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.0.3.tgz", + "integrity": "sha512-ewFoVvsxSyd9QZnknvOWPjirYqdMQhXTeDhJg3hM6C/FeZt0banpGH1nZ0SGMZXHz8NK9uAa2KVIq+jqAIOg4w==", + "requires": { + "@babel/runtime": "^7.6.2" + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jest/transform": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz", + "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^26.6.2", + "babel-plugin-istanbul": "^6.0.0", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.4", + "jest-haste-map": "^26.6.2", + "jest-regex-util": "^26.0.0", + "jest-util": "^26.6.2", + "micromatch": "^4.0.2", + "pirates": "^4.0.1", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@jridgewell/resolve-uri": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz", + "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", + "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.4.tgz", + "integrity": "sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@mdx-js/loader": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/loader/-/loader-1.6.22.tgz", + "integrity": "sha512-9CjGwy595NaxAYp0hF9B/A0lH6C8Rms97e2JS9d3jVUtILn6pT5i5IV965ra3lIWc7Rs1GG1tBdVF7dCowYe6Q==", + "dev": true, + "requires": { + "@mdx-js/mdx": "1.6.22", + "@mdx-js/react": "1.6.22", + "loader-utils": "2.0.0" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", + "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, + "@mdx-js/mdx": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", + "integrity": "sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==", + "dev": true, + "requires": { + "@babel/core": "7.12.9", + "@babel/plugin-syntax-jsx": "7.12.1", + "@babel/plugin-syntax-object-rest-spread": "7.8.3", + "@mdx-js/util": "1.6.22", + "babel-plugin-apply-mdx-type-prop": "1.6.22", + "babel-plugin-extract-import-names": "1.6.22", + "camelcase-css": "2.0.1", + "detab": "2.0.4", + "hast-util-raw": "6.0.1", + "lodash.uniq": "4.5.0", + "mdast-util-to-hast": "10.0.1", + "remark-footnotes": "2.0.0", + "remark-mdx": "1.6.22", + "remark-parse": "8.0.3", + "remark-squeeze-paragraphs": "4.0.0", + "style-to-object": "0.3.0", + "unified": "9.2.0", + "unist-builder": "2.0.3", + "unist-util-visit": "2.0.3" + }, + "dependencies": { + "@babel/core": { + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "@mdx-js/react": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", + "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==", + "dev": true + }, + "@mdx-js/util": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/@mdx-js/util/-/util-1.6.22.tgz", + "integrity": "sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==", + "dev": true + }, + "@mrmlnc/readdir-enhanced": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", + "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==", + "dev": true, + "requires": { + "call-me-maybe": "^1.0.1", + "glob-to-regexp": "^0.3.0" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "dependencies": { + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + } + } + }, + "@nodelib/fs.stat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", + "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, + "requires": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + }, + "dependencies": { + "lru-cache": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz", + "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==", + "dev": true + }, + "semver": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.6.tgz", + "integrity": "sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==", + "dev": true, + "requires": { + "lru-cache": "^7.4.0" + } + } + } + }, + "@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "dev": true, + "requires": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz", + "integrity": "sha512-RbG7h6TuP6nFFYKJwbcToA1rjC1FyPg25NR2noAZ0vKI+la01KTSRPkuVPE+U88jXv7javx2JHglUcL1MHcshQ==", + "dev": true, + "requires": { + "ansi-html-community": "^0.0.8", + "common-path-prefix": "^3.0.0", + "core-js-pure": "^3.8.1", + "error-stack-parser": "^2.0.6", + "find-up": "^5.0.0", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "@react-aria/i18n": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.3.4.tgz", + "integrity": "sha512-1DV3I82UfL2dT8WBI/88TwtokO80B7ISSyuz6rO/6n7q76A/nC2AtVINbrGYrcKsCcxCEoEMxW5RVJ39fcLijA==", + "requires": { + "@babel/runtime": "^7.6.2", + "@internationalized/date": "3.0.0-alpha.1", + "@internationalized/message": "^3.0.2", + "@internationalized/number": "^3.0.2", + "@react-aria/ssr": "^3.0.3", + "@react-aria/utils": "^3.10.0", + "@react-types/shared": "^3.10.0" + } + }, + "@react-aria/interactions": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.7.0.tgz", + "integrity": "sha512-Xomchjb9bqvh3ocil+QCEYFSxsTy8PHEz43mNP6z2yuu3UqTpl2FsWfyKgF/Yy0WKVkyV2dO2uz758KJTCLZhw==", + "requires": { + "@babel/runtime": "^7.6.2", + "@react-aria/utils": "^3.10.0", + "@react-types/shared": "^3.10.0" + } + }, + "@react-aria/overlays": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@react-aria/overlays/-/overlays-3.7.2.tgz", + "integrity": "sha512-KAurJ5MJRnXCPRrO1OdAaXz253cwO5VOOp8wx3/40Zm05o5FBA15ZJZT6BD8rZQOKMCAjkI76tiZQeMQtDULcQ==", + "requires": { + "@babel/runtime": "^7.6.2", + "@react-aria/i18n": "^3.3.2", + "@react-aria/interactions": "^3.5.1", + "@react-aria/utils": "^3.8.2", + "@react-aria/visually-hidden": "^3.2.3", + "@react-stately/overlays": "^3.1.3", + "@react-types/button": "^3.4.1", + "@react-types/overlays": "^3.5.1", + "dom-helpers": "^3.3.1" + } + }, + "@react-aria/ssr": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.1.0.tgz", + "integrity": "sha512-RxqQKmE8sO7TGdrcSlHTcVzMP450hqowtBSd2bBS9oPlcokVkaGq28c3Rwa8ty5ctw4EBCjXqjP7xdcKMGDzug==", + "requires": { + "@babel/runtime": "^7.6.2" + } + }, + "@react-aria/utils": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.11.0.tgz", + "integrity": "sha512-4yFA8E9xqDCUlolYSsoyp/qxrkiQrnEqx1BQOrKDuicpW7MBJ39pJC23YFMpyK2a6xEptc6xJEeIEFJXp57jJw==", + "requires": { + "@babel/runtime": "^7.6.2", + "@react-aria/ssr": "^3.1.0", + "@react-stately/utils": "^3.3.0", + "@react-types/shared": "^3.10.1", + "clsx": "^1.1.1" + } + }, + "@react-aria/visually-hidden": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.2.3.tgz", + "integrity": "sha512-iAe5EFI7obEOwTnIdAwWrKq+CrIJFGTw85v8fXnQ7CIVGRDblX85GOUww9bzQNPDLLRYWS4VF702ii8kV4+JCw==", + "requires": { + "@babel/runtime": "^7.6.2", + "@react-aria/interactions": "^3.5.1", + "@react-aria/utils": "^3.8.2", + "clsx": "^1.1.1" + } + }, + "@react-stately/overlays": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.1.3.tgz", + "integrity": "sha512-X8H/h9F8ZjevwJ7P8ak7v500qQd5x4Y76LsXUXrR6LtcO8FXfp2I+W8sGmBtLZwLQpTJiF1U0WMQqXLE1g6eLA==", + "requires": { + "@babel/runtime": "^7.6.2", + "@react-stately/utils": "^3.2.2", + "@react-types/overlays": "^3.5.1" + } + }, + "@react-stately/utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.3.0.tgz", + "integrity": "sha512-f//Y8q0+FFcS04xvCNvbba7WWRLHzj2AegLgdgwTxsnk9Gb+AyuasdRrRY7bGQhdHuEJ7OIiQZ9EQWndDbrTcg==", + "requires": { + "@babel/runtime": "^7.6.2" + } + }, + "@react-types/button": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.4.1.tgz", + "integrity": "sha512-B54M84LxdEppwjXNlkBEJyMfe9fd+bvFV7R6+NJvupGrZm/LuFNYjFcHk7yjMKWTdWm6DbpIuQz54n5qTW7Vlg==", + "requires": { + "@react-types/shared": "^3.8.0" + } + }, + "@react-types/overlays": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.5.1.tgz", + "integrity": "sha512-T3o6wQ5NNm1rSniIa01bIa6fALC8jbwpYxFMaQRrdEpIvwktt0Fi5Xo6/97+oe4HvzzU0JMhtwWDTdRySvgeZw==", + "requires": { + "@react-types/shared": "^3.8.0" + } + }, + "@react-types/shared": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.10.1.tgz", + "integrity": "sha512-U3dLJtstvOiZ8XLrWdNv9WXuruoDyfIfSXguTs9N0naDdO+M0MIbt/1Hg7Toe43ueAe56GM14IFL+S0/jhv8ow==" + }, + "@rollup/plugin-commonjs": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz", + "integrity": "sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "commondir": "^1.0.1", + "estree-walker": "^2.0.1", + "glob": "^7.1.6", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7", + "resolve": "^1.17.0" + } + }, + "@rollup/plugin-image": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-image/-/plugin-image-2.1.1.tgz", + "integrity": "sha512-AgP4U85zuQJdUopLUCM+hTf45RepgXeTb8EJsleExVy99dIoYpt3ZlDYJdKmAc2KLkNntCDg6BPJvgJU3uGF+g==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "mini-svg-data-uri": "^1.2.3" + } + }, + "@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "dependencies": { + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + } + } + }, + "@storybook/addon-a11y": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.4.13.tgz", + "integrity": "sha512-4N0YkNc5VPy4hb6pvYT3n6epFP1dM5wX1J+hCLcJgs9XIemVsuh/mtB0lKzNy5ZDXndQCmLuoW3jkFspKZVItA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/theming": "6.4.13", + "axe-core": "^4.2.0", + "core-js": "^3.8.2", + "global": "^4.4.0", + "lodash": "^4.17.21", + "react-sizeme": "^3.0.1", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "dev": true + }, + "@storybook/addons": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.13.tgz", + "integrity": "sha512-2oxZ/VOuXUpOvtKGy+fR1FNwyfaTkzKs9I6cZq2zbEGK2q/5x6rtczwNRm5PjK35At+VurMq0E+IHH10JO9vHw==", + "dev": true, + "requires": { + "@storybook/api": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/router": "6.4.13", + "@storybook/theming": "6.4.13", + "@types/webpack-env": "^1.16.0", + "core-js": "^3.8.2", + "global": "^4.4.0", + "regenerator-runtime": "^0.13.7" + } + }, + "@storybook/api": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.4.13.tgz", + "integrity": "sha512-Hr5/dL4tLnQPjrUlVdhsYMSAuJmsZcu3jdfqpjbsDC9S2HNaVtyHGBhQ33jD8+xtXoorsuS7t4SfWzLOgPPflg==", + "dev": true, + "requires": { + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/router": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "regenerator-runtime": "^0.13.7", + "store2": "^2.12.0", + "telejson": "^5.3.2", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/components": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.4.13.tgz", + "integrity": "sha512-edeoYycQMsPaXPyPvYV4Aoiz4A/9kPsZt0Wf2zBGMGX6cpaGV3aoy8pFBl6XSq2hPxIn8JdcB/8MK3/tj35h4w==", + "dev": true, + "requires": { + "@popperjs/core": "^2.6.0", + "@storybook/client-logger": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/theming": "6.4.13", + "@types/color-convert": "^2.0.0", + "@types/overlayscrollbars": "^1.12.0", + "@types/react-syntax-highlighter": "11.0.5", + "color-convert": "^2.0.1", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "markdown-to-jsx": "^7.1.3", + "memoizerific": "^1.11.3", + "overlayscrollbars": "^1.13.1", + "polished": "^4.0.5", + "prop-types": "^15.7.2", + "react-colorful": "^5.1.2", + "react-popper-tooltip": "^3.1.1", + "react-syntax-highlighter": "^13.5.3", + "react-textarea-autosize": "^8.3.0", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/core-events": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.13.tgz", + "integrity": "sha512-zNlzNv7qVXjLf7yfvY9KfLvDY8nVskxrjmz0+21rIqUefS9+7SWBrtJJURpCaoPf/BmACqh/6c1RnuOY7TESnw==", + "dev": true, + "requires": { + "core-js": "^3.8.2" + } + }, + "@storybook/theming": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.13.tgz", + "integrity": "sha512-oWRoNnvO4QnRnplZ74DVdU4k91eqw8y0Xqn6lzZBeC8hq6mYWldgfj1LZ24gJhVtEIa7ZKoyujGUygHaH8WXHw==", + "dev": true, + "requires": { + "@emotion/core": "^10.1.1", + "@emotion/is-prop-valid": "^0.8.6", + "@emotion/styled": "^10.0.27", + "@storybook/client-logger": "6.4.13", + "core-js": "^3.8.2", + "deep-object-diff": "^1.1.0", + "emotion-theming": "^10.0.27", + "global": "^4.4.0", + "memoizerific": "^1.11.3", + "polished": "^4.0.5", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@storybook/addon-actions": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.4.13.tgz", + "integrity": "sha512-Bf/M3Kdq60xj48oXnRCm7+qstWL9wT8rjFPFm7+A0NSfVSlox6pFU5SfPuOI4Za/6Ll2XDaYwsaF3QYHX0jQAA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "polished": "^4.0.5", + "prop-types": "^15.7.2", + "react-inspector": "^5.1.0", + "regenerator-runtime": "^0.13.7", + "telejson": "^5.3.2", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2", + "uuid-browser": "^3.1.0" + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "dev": true + }, + "@storybook/addons": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.13.tgz", + "integrity": "sha512-2oxZ/VOuXUpOvtKGy+fR1FNwyfaTkzKs9I6cZq2zbEGK2q/5x6rtczwNRm5PjK35At+VurMq0E+IHH10JO9vHw==", + "dev": true, + "requires": { + "@storybook/api": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/router": "6.4.13", + "@storybook/theming": "6.4.13", + "@types/webpack-env": "^1.16.0", + "core-js": "^3.8.2", + "global": "^4.4.0", + "regenerator-runtime": "^0.13.7" + } + }, + "@storybook/api": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.4.13.tgz", + "integrity": "sha512-Hr5/dL4tLnQPjrUlVdhsYMSAuJmsZcu3jdfqpjbsDC9S2HNaVtyHGBhQ33jD8+xtXoorsuS7t4SfWzLOgPPflg==", + "dev": true, + "requires": { + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/router": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "regenerator-runtime": "^0.13.7", + "store2": "^2.12.0", + "telejson": "^5.3.2", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/components": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.4.13.tgz", + "integrity": "sha512-edeoYycQMsPaXPyPvYV4Aoiz4A/9kPsZt0Wf2zBGMGX6cpaGV3aoy8pFBl6XSq2hPxIn8JdcB/8MK3/tj35h4w==", + "dev": true, + "requires": { + "@popperjs/core": "^2.6.0", + "@storybook/client-logger": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/theming": "6.4.13", + "@types/color-convert": "^2.0.0", + "@types/overlayscrollbars": "^1.12.0", + "@types/react-syntax-highlighter": "11.0.5", + "color-convert": "^2.0.1", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "markdown-to-jsx": "^7.1.3", + "memoizerific": "^1.11.3", + "overlayscrollbars": "^1.13.1", + "polished": "^4.0.5", + "prop-types": "^15.7.2", + "react-colorful": "^5.1.2", + "react-popper-tooltip": "^3.1.1", + "react-syntax-highlighter": "^13.5.3", + "react-textarea-autosize": "^8.3.0", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/core-events": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.13.tgz", + "integrity": "sha512-zNlzNv7qVXjLf7yfvY9KfLvDY8nVskxrjmz0+21rIqUefS9+7SWBrtJJURpCaoPf/BmACqh/6c1RnuOY7TESnw==", + "dev": true, + "requires": { + "core-js": "^3.8.2" + } + }, + "@storybook/theming": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.13.tgz", + "integrity": "sha512-oWRoNnvO4QnRnplZ74DVdU4k91eqw8y0Xqn6lzZBeC8hq6mYWldgfj1LZ24gJhVtEIa7ZKoyujGUygHaH8WXHw==", + "dev": true, + "requires": { + "@emotion/core": "^10.1.1", + "@emotion/is-prop-valid": "^0.8.6", + "@emotion/styled": "^10.0.27", + "@storybook/client-logger": "6.4.13", + "core-js": "^3.8.2", + "deep-object-diff": "^1.1.0", + "emotion-theming": "^10.0.27", + "global": "^4.4.0", + "memoizerific": "^1.11.3", + "polished": "^4.0.5", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@storybook/addon-backgrounds": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-6.4.13.tgz", + "integrity": "sha512-U+TowEgEHCWifdnaJE5P7kgRHjYrztwpjp/8tX4iXHlCVFBFid+v4EKqXQGbvTzX66g2Yfv/h68NGEpcFW/svQ==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "global": "^4.4.0", + "memoizerific": "^1.11.3", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/addon-controls": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.4.13.tgz", + "integrity": "sha512-XDaeYcwCi4qQ8hGXn4Mbdb6CQGGfZoBm5UjUaWBjDJdo54AyZv3VYdNgWFdiitqk5LRyh2omHD54EditM774NQ==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/node-logger": "6.4.13", + "@storybook/store": "6.4.13", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "lodash": "^4.17.21", + "ts-dedent": "^2.0.0" + } + }, + "@storybook/addon-docs": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-6.4.13.tgz", + "integrity": "sha512-frsHcZD3jabIXxYkenwigJhAiqmbeBztc1cUTMWSZ9kVDJN6h2msq/vD0LEotfjcvDe3XS2HZgBjdDJ1UUUj/g==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/generator": "^7.12.11", + "@babel/parser": "^7.12.11", + "@babel/plugin-transform-react-jsx": "^7.12.12", + "@babel/preset-env": "^7.12.11", + "@jest/transform": "^26.6.2", + "@mdx-js/loader": "^1.6.22", + "@mdx-js/mdx": "^1.6.22", + "@mdx-js/react": "^1.6.22", + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/builder-webpack4": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/csf-tools": "6.4.13", + "@storybook/node-logger": "6.4.13", + "@storybook/postinstall": "6.4.13", + "@storybook/preview-web": "6.4.13", + "@storybook/source-loader": "6.4.13", + "@storybook/store": "6.4.13", + "@storybook/theming": "6.4.13", + "acorn": "^7.4.1", + "acorn-jsx": "^5.3.1", + "acorn-walk": "^7.2.0", + "core-js": "^3.8.2", + "doctrine": "^3.0.0", + "escodegen": "^2.0.0", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "html-tags": "^3.1.0", + "js-string-escape": "^1.0.1", + "loader-utils": "^2.0.0", + "lodash": "^4.17.21", + "nanoid": "^3.1.23", + "p-limit": "^3.1.0", + "prettier": "<=2.3.0", + "prop-types": "^15.7.2", + "react-element-to-jsx-string": "^14.3.4", + "regenerator-runtime": "^0.13.7", + "remark-external-links": "^8.0.0", + "remark-slug": "^6.0.0", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + } + } + }, + "@storybook/addon-essentials": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-6.4.13.tgz", + "integrity": "sha512-ekvyeVckKkffGQMzp6cT0/Mi8Wo1fqF/DGp3vJIcIrExfvuZa/qi8PoHyx+cr8dfI0b8Jf8Lv7qcLIxNnkA5Bg==", + "dev": true, + "requires": { + "@storybook/addon-actions": "6.4.13", + "@storybook/addon-backgrounds": "6.4.13", + "@storybook/addon-controls": "6.4.13", + "@storybook/addon-docs": "6.4.13", + "@storybook/addon-measure": "6.4.13", + "@storybook/addon-outline": "6.4.13", + "@storybook/addon-toolbars": "6.4.13", + "@storybook/addon-viewport": "6.4.13", + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/node-logger": "6.4.13", + "core-js": "^3.8.2", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0" + } + }, + "@storybook/addon-knobs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-knobs/-/addon-knobs-6.4.0.tgz", + "integrity": "sha512-DiH1/5e2AFHoHrncl1qLu18ZHPHzRMMPvOLFz8AWvvmc+VCqTdIaE+tdxKr3e8rYylKllibgvDOzrLjfTNjF+Q==", + "dev": true, + "requires": { + "copy-to-clipboard": "^3.3.1", + "core-js": "^3.8.2", + "escape-html": "^1.0.3", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.20", + "prop-types": "^15.7.2", + "qs": "^6.10.0", + "react-colorful": "^5.1.2", + "react-lifecycles-compat": "^3.0.4", + "react-select": "^3.2.0" + }, + "dependencies": { + "@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "dev": true, + "requires": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, + "@emotion/css": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", + "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", + "dev": true, + "requires": { + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3", + "babel-plugin-emotion": "^10.0.27" + } + }, + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "dev": true + }, + "@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "dev": true, + "requires": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "@emotion/sheet": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", + "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==", + "dev": true + }, + "@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "dev": true + }, + "csstype": { + "version": "2.6.20", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", + "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==", + "dev": true + }, + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "dev": true + }, + "react-select": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-3.2.0.tgz", + "integrity": "sha512-B/q3TnCZXEKItO0fFN/I0tWOX3WJvi/X2wtdffmwSQVRwg5BpValScTO1vdic9AxlUgmeSzib2hAZAwIUQUZGQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "@emotion/cache": "^10.0.9", + "@emotion/core": "^10.0.9", + "@emotion/css": "^10.0.9", + "memoize-one": "^5.0.0", + "prop-types": "^15.6.0", + "react-input-autosize": "^3.0.0", + "react-transition-group": "^4.3.0" + } + } + } + }, + "@storybook/addon-measure": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-6.4.13.tgz", + "integrity": "sha512-uOnJrSWNlMznScCfeXkhqlenLoz6DBgNgBxuP7P6TiF5cxq7Xwv23RX3Hj1nzybP+wvUPEj08FBCh8BqgGOsOA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "core-js": "^3.8.2", + "global": "^4.4.0" + } + }, + "@storybook/addon-outline": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-6.4.13.tgz", + "integrity": "sha512-9BR70PRQeHtED/NkDp6JPRPrpKA43AubgRu4PHUJ0sbaD7o2DMHPKtt2AcsZoL7JSeGDl30cYzfn3pVZDPVxEA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "core-js": "^3.8.2", + "global": "^4.4.0", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0" + } + }, + "@storybook/addon-storysource": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-6.4.13.tgz", + "integrity": "sha512-fpKUCqk3I0Gk/1DduasqS19zvAfTnGn7dfmOtkqzPUEAjbeKcnQDhRBOYr7qykdSjk76HD2+6jrsvcWwaeWFHA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/router": "6.4.13", + "@storybook/source-loader": "6.4.13", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "estraverse": "^5.2.0", + "loader-utils": "^2.0.0", + "prettier": "<=2.3.0", + "prop-types": "^15.7.2", + "react-syntax-highlighter": "^13.5.3", + "regenerator-runtime": "^0.13.7" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "@storybook/addon-toolbars": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-6.4.13.tgz", + "integrity": "sha512-57/bO5MsVnRjmxff+JjQzqjWCzX1KDHR8zla1RpaGsW5ejXcQumW38Xbp0OCscD7wGLL/b58GM/9OIk38LqBwA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "regenerator-runtime": "^0.13.7" + } + }, + "@storybook/addon-viewport": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-6.4.13.tgz", + "integrity": "sha512-EzgPyLRTDgezSlZ7yCKDhR/VcKBECEdd7JCLiuVbfrThVhaKzM9gCx5pDnc0qTflx0DagqkIWFHNVPQt2KnQ3g==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "global": "^4.4.0", + "memoizerific": "^1.11.3", + "prop-types": "^15.7.2", + "regenerator-runtime": "^0.13.7" + } + }, + "@storybook/addons": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.13.tgz", + "integrity": "sha512-2oxZ/VOuXUpOvtKGy+fR1FNwyfaTkzKs9I6cZq2zbEGK2q/5x6rtczwNRm5PjK35At+VurMq0E+IHH10JO9vHw==", + "dev": true, + "requires": { + "@storybook/api": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/router": "6.4.13", + "@storybook/theming": "6.4.13", + "@types/webpack-env": "^1.16.0", + "core-js": "^3.8.2", + "global": "^4.4.0", + "regenerator-runtime": "^0.13.7" + } + }, + "@storybook/api": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.4.13.tgz", + "integrity": "sha512-Hr5/dL4tLnQPjrUlVdhsYMSAuJmsZcu3jdfqpjbsDC9S2HNaVtyHGBhQ33jD8+xtXoorsuS7t4SfWzLOgPPflg==", + "dev": true, + "requires": { + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/router": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/theming": "6.4.13", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "regenerator-runtime": "^0.13.7", + "store2": "^2.12.0", + "telejson": "^5.3.2", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/builder-webpack4": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.4.13.tgz", + "integrity": "sha512-Vjvje/XpFirVY6bOU+ahS2niapjA0Qams5jBE8YnPUhbigqsLOMMpnJ+C505xC6S5VW0lMkhJpCQ1NQyva3sRw==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.12", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "^7.12.7", + "@babel/plugin-proposal-private-methods": "^7.12.1", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.12.1", + "@babel/plugin-transform-block-scoping": "^7.12.12", + "@babel/plugin-transform-classes": "^7.12.1", + "@babel/plugin-transform-destructuring": "^7.12.1", + "@babel/plugin-transform-for-of": "^7.12.1", + "@babel/plugin-transform-parameters": "^7.12.1", + "@babel/plugin-transform-shorthand-properties": "^7.12.1", + "@babel/plugin-transform-spread": "^7.12.1", + "@babel/plugin-transform-template-literals": "^7.12.1", + "@babel/preset-env": "^7.12.11", + "@babel/preset-react": "^7.12.10", + "@babel/preset-typescript": "^7.12.7", + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/channel-postmessage": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/node-logger": "6.4.13", + "@storybook/preview-web": "6.4.13", + "@storybook/router": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/store": "6.4.13", + "@storybook/theming": "6.4.13", + "@storybook/ui": "6.4.13", + "@types/node": "^14.0.10", + "@types/webpack": "^4.41.26", + "autoprefixer": "^9.8.6", + "babel-loader": "^8.0.0", + "babel-plugin-macros": "^2.8.0", + "babel-plugin-polyfill-corejs3": "^0.1.0", + "case-sensitive-paths-webpack-plugin": "^2.3.0", + "core-js": "^3.8.2", + "css-loader": "^3.6.0", + "file-loader": "^6.2.0", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^4.1.6", + "glob": "^7.1.6", + "glob-promise": "^3.4.0", + "global": "^4.4.0", + "html-webpack-plugin": "^4.0.0", + "pnp-webpack-plugin": "1.6.4", + "postcss": "^7.0.36", + "postcss-flexbugs-fixes": "^4.2.1", + "postcss-loader": "^4.2.0", + "raw-loader": "^4.0.2", + "stable": "^0.1.8", + "style-loader": "^1.3.0", + "terser-webpack-plugin": "^4.2.3", + "ts-dedent": "^2.0.0", + "url-loader": "^4.1.1", + "util-deprecate": "^1.0.2", + "webpack": "4", + "webpack-dev-middleware": "^3.7.3", + "webpack-filter-warnings-plugin": "^1.2.1", + "webpack-hot-middleware": "^2.25.1", + "webpack-virtual-modules": "^0.2.2" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz", + "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@types/node": { + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", + "dev": true + }, + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz", + "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.1.5", + "core-js-compat": "^3.8.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, + "requires": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "css-loader": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz", + "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "cssesc": "^3.0.0", + "icss-utils": "^4.1.1", + "loader-utils": "^1.2.3", + "normalize-path": "^3.0.0", + "postcss": "^7.0.32", + "postcss-modules-extract-imports": "^2.0.0", + "postcss-modules-local-by-default": "^3.0.2", + "postcss-modules-scope": "^2.2.0", + "postcss-modules-values": "^3.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^2.7.0", + "semver": "^6.3.0" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lru-cache": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz", + "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==", + "dev": true + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "postcss-loader": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz", + "integrity": "sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==", + "dev": true, + "requires": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.4", + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.4" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.6.tgz", + "integrity": "sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==", + "dev": true, + "requires": { + "lru-cache": "^7.4.0" + } + } + } + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, + "requires": { + "minipass": "^3.1.1" + } + }, + "style-loader": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz", + "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^2.7.0" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, + "terser": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", + "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "dev": true, + "requires": { + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz", + "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==", + "dev": true, + "requires": { + "cacache": "^15.0.5", + "find-cache-dir": "^3.3.1", + "jest-worker": "^26.5.0", + "p-limit": "^3.0.2", + "schema-utils": "^3.0.0", + "serialize-javascript": "^5.0.1", + "source-map": "^0.6.1", + "terser": "^5.3.4", + "webpack-sources": "^1.4.3" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "webpack": { + "version": "4.46.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", + "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/wasm-edit": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "acorn": "^6.4.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.5.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.3", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.3", + "watchpack": "^1.7.4", + "webpack-sources": "^1.4.1" + }, + "dependencies": { + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + }, + "cacache": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz", + "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + }, + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "requires": { + "minimist": "^1.2.6" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "ssri": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz", + "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "terser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", + "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + } + }, + "terser-webpack-plugin": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz", + "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^4.0.0", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + } + } + }, + "@storybook/builder-webpack5": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-6.4.13.tgz", + "integrity": "sha512-mJG9wL3q8gWj6QFL44oP1GiTpsc95HtM5F/yahT3c/zMripAiGcQs7i+Y23pik0X5s4aehq72HTeetTrrYKXYg==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.12", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "^7.12.7", + "@babel/plugin-proposal-private-methods": "^7.12.1", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.12.1", + "@babel/plugin-transform-block-scoping": "^7.12.12", + "@babel/plugin-transform-classes": "^7.12.1", + "@babel/plugin-transform-destructuring": "^7.12.1", + "@babel/plugin-transform-for-of": "^7.12.1", + "@babel/plugin-transform-parameters": "^7.12.1", + "@babel/plugin-transform-shorthand-properties": "^7.12.1", + "@babel/plugin-transform-spread": "^7.12.1", + "@babel/preset-env": "^7.12.11", + "@babel/preset-react": "^7.12.10", + "@babel/preset-typescript": "^7.12.7", + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/channel-postmessage": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/node-logger": "6.4.13", + "@storybook/preview-web": "6.4.13", + "@storybook/router": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/store": "6.4.13", + "@storybook/theming": "6.4.13", + "@types/node": "^14.0.10", + "babel-loader": "^8.0.0", + "babel-plugin-macros": "^3.0.1", + "babel-plugin-polyfill-corejs3": "^0.1.0", + "case-sensitive-paths-webpack-plugin": "^2.3.0", + "core-js": "^3.8.2", + "css-loader": "^5.0.1", + "fork-ts-checker-webpack-plugin": "^6.0.4", + "glob": "^7.1.6", + "glob-promise": "^3.4.0", + "html-webpack-plugin": "^5.0.0", + "path-browserify": "^1.0.1", + "stable": "^0.1.8", + "style-loader": "^2.0.0", + "terser-webpack-plugin": "^5.0.3", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2", + "webpack": "^5.9.0", + "webpack-dev-middleware": "^4.1.0", + "webpack-hot-middleware": "^2.25.1", + "webpack-virtual-modules": "^0.4.1" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz", + "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true + }, + "@types/node": { + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", + "dev": true + }, + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz", + "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.1.5", + "core-js-compat": "^3.8.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "clean-css": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", + "integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.6.tgz", + "integrity": "sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==", + "dev": true, + "requires": { + "lru-cache": "^7.4.0" + } + } + } + }, + "fork-ts-checker-webpack-plugin": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.1.tgz", + "integrity": "sha512-x1wumpHOEf4gDROmKTaB6i4/Q6H3LwmjVO7fIX47vBwlZbtPjU33hgoMuD/Q/y6SU8bnuYSoN6ZQOLshGp0T/g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, + "semver": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.6.tgz", + "integrity": "sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==", + "dev": true, + "requires": { + "lru-cache": "^7.4.0" + } + } + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "requires": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + } + }, + "html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", + "dev": true, + "requires": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "dependencies": { + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + } + } + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "lru-cache": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz", + "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==", + "dev": true + }, + "path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "requires": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "requires": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "terser": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", + "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "dev": true, + "requires": { + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "webpack-dev-middleware": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-4.3.0.tgz", + "integrity": "sha512-PjwyVY95/bhBh6VUqt6z4THplYcsvQ8YNNBTBM873xLVmw8FLeALn0qurHbs9EmcfhzQis/eoqypSnZeuUz26w==", + "dev": true, + "requires": { + "colorette": "^1.2.2", + "mem": "^8.1.1", + "memfs": "^3.2.2", + "mime-types": "^2.1.30", + "range-parser": "^1.2.1", + "schema-utils": "^3.0.0" + } + }, + "webpack-virtual-modules": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.3.tgz", + "integrity": "sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw==", + "dev": true + } + } + }, + "@storybook/channel-postmessage": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.4.13.tgz", + "integrity": "sha512-fyju7H/t2oDp9yci6KImRDPr9FnGV6B0juJ+2kEtVAmeDo55BScjT96SQuS/uk4c0wo6NZMrCt6HiC4zOmrD6g==", + "dev": true, + "requires": { + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "core-js": "^3.8.2", + "global": "^4.4.0", + "qs": "^6.10.0", + "telejson": "^5.3.2" + } + }, + "@storybook/channel-websocket": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/channel-websocket/-/channel-websocket-6.4.13.tgz", + "integrity": "sha512-edc/KRF2dpMyCI67ik8loo2cNh7TUP8HoO/YJBVPTEGmOQxMWgmYs+loTd1xoZAFBdkVKvLXBiu8umPpdh2MpA==", + "dev": true, + "requires": { + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "core-js": "^3.8.2", + "global": "^4.4.0", + "telejson": "^5.3.2" + } + }, + "@storybook/channels": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.4.13.tgz", + "integrity": "sha512-QWvm2TiqPZVPQLBq7ETcABNi17HIhNaXhJJUyNFBBXFtAHcbzMRFEBi6gkCVXK7QdtFo3Z68TU5htDkwjYVerg==", + "dev": true, + "requires": { + "core-js": "^3.8.2", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/client-api": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.4.13.tgz", + "integrity": "sha512-YoF0iKeOTv06HFTLSg1M8Fs9JZwFcNhGFHXv7/LtuTZ9n6ATgaZm7eTTdKrn1d8Qjxql7c7Lm/7mdZgus9ByBA==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/channel-postmessage": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/store": "6.4.13", + "@types/qs": "^6.9.5", + "@types/webpack-env": "^1.16.0", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "qs": "^6.10.0", + "regenerator-runtime": "^0.13.7", + "store2": "^2.12.0", + "synchronous-promise": "^2.0.15", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/client-logger": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.4.13.tgz", + "integrity": "sha512-VPrrgJRURztXAKTeHOpzKMAHnNupkGApUDNlPIs0Qxyn5gaSiy806q4XPoROno3mVgEe+7Chf86hRiL8pJnlCA==", + "dev": true, + "requires": { + "core-js": "^3.8.2", + "global": "^4.4.0" + } + }, + "@storybook/components": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.4.13.tgz", + "integrity": "sha512-edeoYycQMsPaXPyPvYV4Aoiz4A/9kPsZt0Wf2zBGMGX6cpaGV3aoy8pFBl6XSq2hPxIn8JdcB/8MK3/tj35h4w==", + "dev": true, + "requires": { + "@popperjs/core": "^2.6.0", + "@storybook/client-logger": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/theming": "6.4.13", + "@types/color-convert": "^2.0.0", + "@types/overlayscrollbars": "^1.12.0", + "@types/react-syntax-highlighter": "11.0.5", + "color-convert": "^2.0.1", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "markdown-to-jsx": "^7.1.3", + "memoizerific": "^1.11.3", + "overlayscrollbars": "^1.13.1", + "polished": "^4.0.5", + "prop-types": "^15.7.2", + "react-colorful": "^5.1.2", + "react-popper-tooltip": "^3.1.1", + "react-syntax-highlighter": "^13.5.3", + "react-textarea-autosize": "^8.3.0", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "@storybook/core": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-6.4.13.tgz", + "integrity": "sha512-OSbji5w4jrGNALbxJwktZhi8qw4bGgL88dL72O40173b8ROLBOGkEkkz/BpHbqx2PhS9sGVNVMK2b2BwAiiu7g==", + "dev": true, + "requires": { + "@storybook/core-client": "6.4.13", + "@storybook/core-server": "6.4.13" + } + }, + "@storybook/core-client": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.4.13.tgz", + "integrity": "sha512-1m7cAlF16mtVdSNmP8a4z00GCkw2dMyUyJX8snzgYGLD5FaqPLyNGJIidqllHsBUXBfEL2FSu+E9QygK12+O1w==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/channel-postmessage": "6.4.13", + "@storybook/channel-websocket": "6.4.13", + "@storybook/client-api": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/preview-web": "6.4.13", + "@storybook/store": "6.4.13", + "@storybook/ui": "6.4.13", + "airbnb-js-shims": "^2.2.1", + "ansi-to-html": "^0.6.11", + "core-js": "^3.8.2", + "global": "^4.4.0", + "lodash": "^4.17.21", + "qs": "^6.10.0", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "unfetch": "^4.2.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/core-common": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.4.13.tgz", + "integrity": "sha512-KoFa4yktuqWsW+/O6uc7iba25X9eKhp80l9tHsa1RWE94mQdCBUo5VsNoe35JvqFSDOspQ+brCe6vBUaIYe+cQ==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.12", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "^7.12.7", + "@babel/plugin-proposal-private-methods": "^7.12.1", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.12.1", + "@babel/plugin-transform-block-scoping": "^7.12.12", + "@babel/plugin-transform-classes": "^7.12.1", + "@babel/plugin-transform-destructuring": "^7.12.1", + "@babel/plugin-transform-for-of": "^7.12.1", + "@babel/plugin-transform-parameters": "^7.12.1", + "@babel/plugin-transform-shorthand-properties": "^7.12.1", + "@babel/plugin-transform-spread": "^7.12.1", + "@babel/preset-env": "^7.12.11", + "@babel/preset-react": "^7.12.10", + "@babel/preset-typescript": "^7.12.7", + "@babel/register": "^7.12.1", + "@storybook/node-logger": "6.4.13", + "@storybook/semver": "^7.3.2", + "@types/node": "^14.0.10", + "@types/pretty-hrtime": "^1.0.0", + "babel-loader": "^8.0.0", + "babel-plugin-macros": "^3.0.1", + "babel-plugin-polyfill-corejs3": "^0.1.0", + "chalk": "^4.1.0", + "core-js": "^3.8.2", + "express": "^4.17.1", + "file-system-cache": "^1.0.5", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.0.4", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "handlebars": "^4.7.7", + "interpret": "^2.2.0", + "json5": "^2.1.3", + "lazy-universal-dotenv": "^3.0.1", + "picomatch": "^2.3.0", + "pkg-dir": "^5.0.0", + "pretty-hrtime": "^1.0.3", + "resolve-from": "^5.0.0", + "slash": "^3.0.0", + "telejson": "^5.3.2", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2", + "webpack": "4" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "dependencies": { + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + } + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz", + "integrity": "sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@types/node": { + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", + "dev": true + }, + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz", + "integrity": "sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.1.5", + "core-js-compat": "^3.8.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "fork-ts-checker-webpack-plugin": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.1.tgz", + "integrity": "sha512-x1wumpHOEf4gDROmKTaB6i4/Q6H3LwmjVO7fIX47vBwlZbtPjU33hgoMuD/Q/y6SU8bnuYSoN6ZQOLshGp0T/g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "semver": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.6.tgz", + "integrity": "sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==", + "dev": true, + "requires": { + "lru-cache": "^7.4.0" + } + } + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lru-cache": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz", + "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "terser-webpack-plugin": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz", + "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^4.0.0", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + }, + "dependencies": { + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "webpack": { + "version": "4.46.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", + "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/wasm-edit": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "acorn": "^6.4.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.5.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.3", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.3", + "watchpack": "^1.7.4", + "webpack-sources": "^1.4.1" + }, + "dependencies": { + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + } + } + } + } + }, + "@storybook/core-events": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.13.tgz", + "integrity": "sha512-zNlzNv7qVXjLf7yfvY9KfLvDY8nVskxrjmz0+21rIqUefS9+7SWBrtJJURpCaoPf/BmACqh/6c1RnuOY7TESnw==", + "dev": true, + "requires": { + "core-js": "^3.8.2" + } + }, + "@storybook/core-server": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.4.13.tgz", + "integrity": "sha512-i3zrtHHkV6/b+jJF65BQu+YuXen+T/MF1f5J+li5nvJnLKhssVQmvpGvWyJezT3OgFkC1+BFBokFY6NXHHw77g==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "^0.5.3", + "@storybook/builder-webpack4": "6.4.13", + "@storybook/core-client": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/csf-tools": "6.4.13", + "@storybook/manager-webpack4": "6.4.13", + "@storybook/node-logger": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/store": "6.4.13", + "@types/node": "^14.0.10", + "@types/node-fetch": "^2.5.7", + "@types/pretty-hrtime": "^1.0.0", + "@types/webpack": "^4.41.26", + "better-opn": "^2.1.1", + "boxen": "^5.1.2", + "chalk": "^4.1.0", + "cli-table3": "^0.6.1", + "commander": "^6.2.1", + "compression": "^1.7.4", + "core-js": "^3.8.2", + "cpy": "^8.1.2", + "detect-port": "^1.3.0", + "express": "^4.17.1", + "file-system-cache": "^1.0.5", + "fs-extra": "^9.0.1", + "globby": "^11.0.2", + "ip": "^1.1.5", + "lodash": "^4.17.21", + "node-fetch": "^2.6.1", + "pretty-hrtime": "^1.0.3", + "prompts": "^2.4.0", + "regenerator-runtime": "^0.13.7", + "serve-favicon": "^2.5.0", + "slash": "^3.0.0", + "telejson": "^5.3.3", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2", + "watchpack": "^2.2.0", + "webpack": "4", + "ws": "^8.2.3" + }, + "dependencies": { + "@types/node": { + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", + "dev": true + }, + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "terser-webpack-plugin": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz", + "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^4.0.0", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "watchpack": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", + "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack": { + "version": "4.46.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", + "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/wasm-edit": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "acorn": "^6.4.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.5.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.3", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.3", + "watchpack": "^1.7.4", + "webpack-sources": "^1.4.1" + }, + "dependencies": { + "watchpack": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz", + "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==", + "dev": true, + "requires": { + "chokidar": "^3.4.1", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0", + "watchpack-chokidar2": "^2.0.1" + } + } + } + } + } + }, + "@storybook/csf": { + "version": "0.0.2--canary.87bc651.0", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.87bc651.0.tgz", + "integrity": "sha512-ajk1Uxa+rBpFQHKrCcTmJyQBXZ5slfwHVEaKlkuFaW77it8RgbPJp/ccna3sgoi8oZ7FkkOyvv1Ve4SmwFqRqw==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "@storybook/csf-tools": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.4.13.tgz", + "integrity": "sha512-eEYQdr/N4bsiQFxNEUkfQ/KyqdnUecwFS7V1k16/m/dP7cfinwW2Yo+9t77uWe3Qmzj9RbM6jrdWxXEUZ6MwvQ==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/generator": "^7.12.11", + "@babel/parser": "^7.12.11", + "@babel/plugin-transform-react-jsx": "^7.12.12", + "@babel/preset-env": "^7.12.11", + "@babel/traverse": "^7.12.11", + "@babel/types": "^7.12.11", + "@mdx-js/mdx": "^1.6.22", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "core-js": "^3.8.2", + "fs-extra": "^9.0.1", + "global": "^4.4.0", + "js-string-escape": "^1.0.1", + "lodash": "^4.17.21", + "prettier": "<=2.3.0", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + } + } + }, + "@storybook/manager-webpack4": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.4.13.tgz", + "integrity": "sha512-aUUIvSf1nUSuPEdLFcbXbEbm+WlBrpX+Ce+Ee5zuMpggfiMeq4H4UB5QuluB8oLUcJA/ZoQZ9m4pPfUZDH0O0w==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/plugin-transform-template-literals": "^7.12.1", + "@babel/preset-react": "^7.12.10", + "@storybook/addons": "6.4.13", + "@storybook/core-client": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/node-logger": "6.4.13", + "@storybook/theming": "6.4.13", + "@storybook/ui": "6.4.13", + "@types/node": "^14.0.10", + "@types/webpack": "^4.41.26", + "babel-loader": "^8.0.0", + "case-sensitive-paths-webpack-plugin": "^2.3.0", + "chalk": "^4.1.0", + "core-js": "^3.8.2", + "css-loader": "^3.6.0", + "express": "^4.17.1", + "file-loader": "^6.2.0", + "file-system-cache": "^1.0.5", + "find-up": "^5.0.0", + "fs-extra": "^9.0.1", + "html-webpack-plugin": "^4.0.0", + "node-fetch": "^2.6.1", + "pnp-webpack-plugin": "1.6.4", + "read-pkg-up": "^7.0.1", + "regenerator-runtime": "^0.13.7", + "resolve-from": "^5.0.0", + "style-loader": "^1.3.0", + "telejson": "^5.3.2", + "terser-webpack-plugin": "^4.2.3", + "ts-dedent": "^2.0.0", + "url-loader": "^4.1.1", + "util-deprecate": "^1.0.2", + "webpack": "4", + "webpack-dev-middleware": "^3.7.3", + "webpack-virtual-modules": "^0.2.2" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@types/node": { + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", + "dev": true + }, + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, + "requires": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "css-loader": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz", + "integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "cssesc": "^3.0.0", + "icss-utils": "^4.1.1", + "loader-utils": "^1.2.3", + "normalize-path": "^3.0.0", + "postcss": "^7.0.32", + "postcss-modules-extract-imports": "^2.0.0", + "postcss-modules-local-by-default": "^3.0.2", + "postcss-modules-scope": "^2.2.0", + "postcss-modules-values": "^3.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^2.7.0", + "semver": "^6.3.0" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, + "requires": { + "minipass": "^3.1.1" + } + }, + "style-loader": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.3.0.tgz", + "integrity": "sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^2.7.0" + }, + "dependencies": { + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + } + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "terser": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", + "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "dev": true, + "requires": { + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz", + "integrity": "sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==", + "dev": true, + "requires": { + "cacache": "^15.0.5", + "find-cache-dir": "^3.3.1", + "jest-worker": "^26.5.0", + "p-limit": "^3.0.2", + "schema-utils": "^3.0.0", + "serialize-javascript": "^5.0.1", + "source-map": "^0.6.1", + "terser": "^5.3.4", + "webpack-sources": "^1.4.3" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "webpack": { + "version": "4.46.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", + "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/wasm-edit": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "acorn": "^6.4.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.5.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.3", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.3", + "watchpack": "^1.7.4", + "webpack-sources": "^1.4.1" + }, + "dependencies": { + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + }, + "cacache": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz", + "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + }, + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "requires": { + "minimist": "^1.2.6" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "ssri": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz", + "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "terser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", + "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + } + }, + "terser-webpack-plugin": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz", + "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^4.0.0", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + } + } + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "@storybook/manager-webpack5": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/manager-webpack5/-/manager-webpack5-6.4.13.tgz", + "integrity": "sha512-J+FBmAtwO5Hsw/33CNiNOO0BFNPfcwkjqwlhe1t9wI2Fq4xo0r6lQygpghVAPIg8PxnKxSi/AVZAnbxa2iBl7w==", + "dev": true, + "requires": { + "@babel/core": "^7.12.10", + "@babel/plugin-transform-template-literals": "^7.12.1", + "@babel/preset-react": "^7.12.10", + "@storybook/addons": "6.4.13", + "@storybook/core-client": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/node-logger": "6.4.13", + "@storybook/theming": "6.4.13", + "@storybook/ui": "6.4.13", + "@types/node": "^14.0.10", + "babel-loader": "^8.0.0", + "case-sensitive-paths-webpack-plugin": "^2.3.0", + "chalk": "^4.1.0", + "core-js": "^3.8.2", + "css-loader": "^5.0.1", + "express": "^4.17.1", + "file-system-cache": "^1.0.5", + "find-up": "^5.0.0", + "fs-extra": "^9.0.1", + "html-webpack-plugin": "^5.0.0", + "node-fetch": "^2.6.1", + "read-pkg-up": "^7.0.1", + "regenerator-runtime": "^0.13.7", + "resolve-from": "^5.0.0", + "style-loader": "^2.0.0", + "telejson": "^5.3.2", + "terser-webpack-plugin": "^5.0.3", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2", + "webpack": "^5.9.0", + "webpack-dev-middleware": "^4.1.0", + "webpack-virtual-modules": "^0.4.1" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true + }, + "@types/node": { + "version": "14.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", + "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", + "dev": true + }, + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "clean-css": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", + "integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.6.tgz", + "integrity": "sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==", + "dev": true, + "requires": { + "lru-cache": "^7.4.0" + } + } + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "requires": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + } + }, + "html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", + "dev": true, + "requires": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + } + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lru-cache": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.8.0.tgz", + "integrity": "sha512-AmXqneQZL3KZMIgBpaPTeI6pfwh+xQ2vutMsyqOu1TBdEXFZgpG/80wuJ531w2ZN7TI0/oc8CPxzh/DKQudZqg==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "requires": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "requires": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "terser": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", + "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "dev": true, + "requires": { + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "webpack-dev-middleware": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-4.3.0.tgz", + "integrity": "sha512-PjwyVY95/bhBh6VUqt6z4THplYcsvQ8YNNBTBM873xLVmw8FLeALn0qurHbs9EmcfhzQis/eoqypSnZeuUz26w==", + "dev": true, + "requires": { + "colorette": "^1.2.2", + "mem": "^8.1.1", + "memfs": "^3.2.2", + "mime-types": "^2.1.30", + "range-parser": "^1.2.1", + "schema-utils": "^3.0.0" + } + }, + "webpack-virtual-modules": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.4.3.tgz", + "integrity": "sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw==", + "dev": true + } + } + }, + "@storybook/node-logger": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.4.13.tgz", + "integrity": "sha512-L0WJjJ3MTkdSpCaC1xSJ1/SJzblQ8E3tYKSI3M3890711gfxtSM/9CfuatQ6ibTXcm5d8bW6TUJayTD4I8vUPg==", + "dev": true, + "requires": { + "@types/npmlog": "^4.1.2", + "chalk": "^4.1.0", + "core-js": "^3.8.2", + "npmlog": "^5.0.1", + "pretty-hrtime": "^1.0.3" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@storybook/postinstall": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/postinstall/-/postinstall-6.4.13.tgz", + "integrity": "sha512-7SzFt0BDFOI0vFKc0Ba5slkQaur3AEN9211U7pBbzgp6HxBjiTT5fqLET+dvk30ke8YtOauj0LZ+uHx9TNYrBA==", + "dev": true, + "requires": { + "core-js": "^3.8.2" + } + }, + "@storybook/preview-web": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/preview-web/-/preview-web-6.4.13.tgz", + "integrity": "sha512-z21N09iWrzi2sX5+06aNvxPVp0rzntO7seM7zIPxqpFiEsAoPodkVJka3YyJpgK3S2JtgipmIgvLJeLXENLr3g==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/channel-postmessage": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/store": "6.4.13", + "ansi-to-html": "^0.6.11", + "core-js": "^3.8.2", + "global": "^4.4.0", + "lodash": "^4.17.21", + "qs": "^6.10.0", + "regenerator-runtime": "^0.13.7", + "synchronous-promise": "^2.0.15", + "ts-dedent": "^2.0.0", + "unfetch": "^4.2.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/react": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-6.4.13.tgz", + "integrity": "sha512-bmHGeAAad0qoEfselx3qvWlPf1fWccDgki3TneFWYTSoybZOuu0PWJp+M7kqWMxcyvdwQImjA9F+vCc7CUuF9w==", + "dev": true, + "requires": { + "@babel/preset-flow": "^7.12.1", + "@babel/preset-react": "^7.12.10", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.1", + "@storybook/addons": "6.4.13", + "@storybook/core": "6.4.13", + "@storybook/core-common": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/node-logger": "6.4.13", + "@storybook/react-docgen-typescript-plugin": "1.0.2-canary.253f8c1.0", + "@storybook/semver": "^7.3.2", + "@storybook/store": "6.4.13", + "@types/webpack-env": "^1.16.0", + "babel-plugin-add-react-displayname": "^0.0.5", + "babel-plugin-named-asset-import": "^0.3.1", + "babel-plugin-react-docgen": "^4.2.1", + "core-js": "^3.8.2", + "global": "^4.4.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2", + "react-refresh": "^0.11.0", + "read-pkg-up": "^7.0.1", + "regenerator-runtime": "^0.13.7", + "ts-dedent": "^2.0.0", + "webpack": "4" + }, + "dependencies": { + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "terser-webpack-plugin": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz", + "integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^4.0.0", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "webpack": { + "version": "4.46.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz", + "integrity": "sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/wasm-edit": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "acorn": "^6.4.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^4.5.0", + "eslint-scope": "^4.0.3", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.3", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.3", + "watchpack": "^1.7.4", + "webpack-sources": "^1.4.1" + } + } + } + }, + "@storybook/react-docgen-typescript-plugin": { + "version": "1.0.2-canary.253f8c1.0", + "resolved": "https://registry.npmjs.org/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.2-canary.253f8c1.0.tgz", + "integrity": "sha512-mmoRG/rNzAiTbh+vGP8d57dfcR2aP+5/Ll03KKFyfy5FqWFm/Gh7u27ikx1I3LmVMI8n6jh5SdWMkMKon7/tDw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "endent": "^2.0.1", + "find-cache-dir": "^3.3.1", + "flat-cache": "^3.0.4", + "micromatch": "^4.0.2", + "react-docgen-typescript": "^2.0.0", + "tslib": "^2.0.0" + }, + "dependencies": { + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + } + } + }, + "@storybook/router": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.4.13.tgz", + "integrity": "sha512-6KbIpSL8QhGglzGb+tWTvAF/2EVpmgwlU5VP6Xs3GANcOc3VeXWl1fcJD6CNPp2DHwjkblW+21dcoHqfljnTmg==", + "dev": true, + "requires": { + "@storybook/client-logger": "6.4.13", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "history": "5.0.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "qs": "^6.10.0", + "react-router": "^6.0.0", + "react-router-dom": "^6.0.0", + "ts-dedent": "^2.0.0" + }, + "dependencies": { + "history": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.0.0.tgz", + "integrity": "sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.7.6" + } + }, + "react-router": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "dev": true, + "requires": { + "history": "^5.2.0" + }, + "dependencies": { + "history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.7.6" + } + } + } + }, + "react-router-dom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "dev": true, + "requires": { + "history": "^5.2.0", + "react-router": "6.3.0" + }, + "dependencies": { + "history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.7.6" + } + } + } + } + } + }, + "@storybook/semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==", + "dev": true, + "requires": { + "core-js": "^3.6.5", + "find-up": "^4.1.0" + } + }, + "@storybook/source-loader": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.4.13.tgz", + "integrity": "sha512-3M2VRt/ABGpm2G9MxkWAufvacPFDdHl+gvkNOq4lRhFw8uAh78xoXb1n0heOOuYGtJbw1+UHNOh4ahZGCWYxJg==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "core-js": "^3.8.2", + "estraverse": "^5.2.0", + "global": "^4.4.0", + "loader-utils": "^2.0.0", + "lodash": "^4.17.21", + "prettier": "<=2.3.0", + "regenerator-runtime": "^0.13.7" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "@storybook/store": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/store/-/store-6.4.13.tgz", + "integrity": "sha512-VUDYwn1PHTa92kaJFCWqP+QS5wsHO9us2prhHnD7k9qvvQrbxD2DewtGxdT7cRHbZI8jY5CiqMVKilZRaXSM3Q==", + "dev": true, + "requires": { + "@storybook/addons": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "core-js": "^3.8.2", + "fast-deep-equal": "^3.1.3", + "global": "^4.4.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "regenerator-runtime": "^0.13.7", + "slash": "^3.0.0", + "stable": "^0.1.8", + "synchronous-promise": "^2.0.15", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/theming": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.13.tgz", + "integrity": "sha512-oWRoNnvO4QnRnplZ74DVdU4k91eqw8y0Xqn6lzZBeC8hq6mYWldgfj1LZ24gJhVtEIa7ZKoyujGUygHaH8WXHw==", + "dev": true, + "requires": { + "@emotion/core": "^10.1.1", + "@emotion/is-prop-valid": "^0.8.6", + "@emotion/styled": "^10.0.27", + "@storybook/client-logger": "6.4.13", + "core-js": "^3.8.2", + "deep-object-diff": "^1.1.0", + "emotion-theming": "^10.0.27", + "global": "^4.4.0", + "memoizerific": "^1.11.3", + "polished": "^4.0.5", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@storybook/ui": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@storybook/ui/-/ui-6.4.13.tgz", + "integrity": "sha512-EvpWk2iHjfiWkuMuzYz5fXl4r7S9Q80EtFFVkaBEZfIoKjvIkxppGQ3kz892ZdXzuazzvni2qcb7OJA9S7AgLw==", + "dev": true, + "requires": { + "@emotion/core": "^10.1.1", + "@storybook/addons": "6.4.13", + "@storybook/api": "6.4.13", + "@storybook/channels": "6.4.13", + "@storybook/client-logger": "6.4.13", + "@storybook/components": "6.4.13", + "@storybook/core-events": "6.4.13", + "@storybook/router": "6.4.13", + "@storybook/semver": "^7.3.2", + "@storybook/theming": "6.4.13", + "copy-to-clipboard": "^3.3.1", + "core-js": "^3.8.2", + "core-js-pure": "^3.8.2", + "downshift": "^6.0.15", + "emotion-theming": "^10.0.27", + "fuse.js": "^3.6.1", + "global": "^4.4.0", + "lodash": "^4.17.21", + "markdown-to-jsx": "^7.1.3", + "memoizerific": "^1.11.3", + "polished": "^4.0.5", + "qs": "^6.10.0", + "react-draggable": "^4.4.3", + "react-helmet-async": "^1.0.7", + "react-sizeme": "^3.0.1", + "regenerator-runtime": "^0.13.7", + "resolve-from": "^5.0.0", + "store2": "^2.12.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true + }, + "@types/aria-query": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==" + }, + "@types/cheerio": { + "version": "0.22.30", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.30.tgz", + "integrity": "sha512-t7ZVArWZlq3dFa9Yt33qFBQIK4CQd1Q3UJp0V+UhP6vgLWLM6Qug7vZuRSGXg45zXeB1Fm5X2vmBkEX58LV2Tw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/clipboard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/clipboard/-/clipboard-2.0.1.tgz", + "integrity": "sha512-gJJX9Jjdt3bIAePQRRjYWG20dIhAgEqonguyHxXuqALxsoDsDLimihqrSg8fXgVTJ4KZCzkfglKtwsh/8dLfbA==", + "dev": true + }, + "@types/color-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.0.tgz", + "integrity": "sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==", + "dev": true, + "requires": { + "@types/color-name": "*" + } + }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "@types/common-tags": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/common-tags/-/common-tags-1.8.1.tgz", + "integrity": "sha512-20R/mDpKSPWdJs5TOpz3e7zqbeCNuMCPhV7Yndk9KU2Rbij2r5W4RzwDPkzC+2lzUqXYu9rFzTktCBnDjHuNQg==", + "dev": true + }, + "@types/enzyme": { + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.10.5.tgz", + "integrity": "sha512-R+phe509UuUYy9Tk0YlSbipRpfVtIzb/9BHn5pTEtjJTF5LXvUjrIQcZvNyANNEyFrd2YGs196PniNT1fgvOQA==", + "dev": true, + "requires": { + "@types/cheerio": "*", + "@types/react": "*" + } + }, + "@types/enzyme-adapter-react-16": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.6.tgz", + "integrity": "sha512-VonDkZ15jzqDWL8mPFIQnnLtjwebuL9YnDkqeCDYnB4IVgwUm0mwKkqhrxLL6mb05xm7qqa3IE95m8CZE9imCg==", + "dev": true, + "requires": { + "@types/enzyme": "*" + } + }, + "@types/eslint": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.2.2.tgz", + "integrity": "sha512-nQxgB8/Sg+QKhnV8e0WzPpxjIGT3tuJDDzybkDi8ItE/IgTlHo07U0shaIjzhcvQxlq9SDRE42lsJ23uvEgJ2A==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "requires": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/graceful-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", + "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/hast": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", + "integrity": "sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==", + "dev": true, + "requires": { + "@types/unist": "*" + } + }, + "@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + }, + "dependencies": { + "@types/react": { + "version": "17.0.38", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.38.tgz", + "integrity": "sha512-SI92X1IA+FMnP3qM5m4QReluXzhcmovhZnLNm3pyeQlooi02qI7sLiepEYqT678uNiyc25XfCqxREFpy3W7YhQ==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "csstype": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + } + } + }, + "@types/html-minifier-terser": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", + "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==", + "dev": true + }, + "@types/is-function": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.1.tgz", + "integrity": "sha512-A79HEEiwXTFtfY+Bcbo58M2GRYzCr9itHWzbzHVFNEYCcoU/MMGwYYf721gBrnhpj1s6RGVVha/IgNFnR0Iw/Q==", + "dev": true + }, + "@types/is-hotkey": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.1.tgz", + "integrity": "sha512-QzVKww91fJv/KzARJBS/Im5GS2A8iE64E1HxOed72EmYOvPLG4PBw77QCIUjFl7VwWB3G/SVrxsHedJD/wtn1A==", + "dev": true + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", + "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "@types/jest": { + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz", + "integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==", + "dev": true, + "requires": { + "jest-diff": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, + "@types/jquery": { + "version": "3.3.38", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.38.tgz", + "integrity": "sha512-nkDvmx7x/6kDM5guu/YpXkGZ/Xj/IwGiLDdKM99YA5Vag7pjGyTJ8BNUh/6hxEn/sEu5DKtyRgnONJ7EmOoKrA==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, + "@types/js-cookie": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", + "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" + }, + "@types/json-schema": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", + "dev": true + }, + "@types/lodash": { + "version": "4.14.123", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.123.tgz", + "integrity": "sha512-pQvPkc4Nltyx7G1Ww45OjVqUsJP4UsZm+GWJpigXgkikZqJgRm4c48g027o6tdgubWHwFRF15iFd+Y4Pmqv6+Q==", + "dev": true + }, + "@types/mdast": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", + "integrity": "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==", + "dev": true, + "requires": { + "@types/unist": "*" + } + }, + "@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true + }, + "@types/mock-raf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mock-raf/-/mock-raf-1.0.2.tgz", + "integrity": "sha1-3g3xaxy+JHXLGkaAoZ80TzhtglI=", + "dev": true + }, + "@types/node": { + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==", + "dev": true + }, + "@types/node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, + "@types/normalize-package-data": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", + "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", + "dev": true + }, + "@types/npmlog": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-4.1.4.tgz", + "integrity": "sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==", + "dev": true + }, + "@types/overlayscrollbars": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@types/overlayscrollbars/-/overlayscrollbars-1.12.1.tgz", + "integrity": "sha512-V25YHbSoKQN35UasHf0EKD9U2vcmexRSp78qa8UglxFH8H3D+adEa9zGZwrqpH4TdvqeMrgMqVqsLB4woAryrQ==", + "dev": true + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, + "@types/parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz", + "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==", + "dev": true + }, + "@types/pretty-hrtime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", + "integrity": "sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==", + "dev": true + }, + "@types/prismjs": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.16.0.tgz", + "integrity": "sha512-mEyuziLrfDCQ4juQP1k706BUU/c8OGn/ZFl69AXXY6dStHClKX4P+N8+rhqpul1vRDA2VOygzMRSJJZHyDEOfw==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" + }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "@types/react": { + "version": "17.0.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.30.tgz", + "integrity": "sha512-3Dt/A8gd3TCXi2aRe84y7cK1K8G+N9CZRDG8kDGguOKa0kf/ZkSwTmVIDPsm/KbQOVMaDJXwhBtuOXxqwdpWVg==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-1lBBxVSutE8CQM37Jq7KvJwuA94qaEEqsx+G0dnwzG6Sfwf6JGcNeFk5jjjhJli1q2naeMZm+D/dvT/zyX4QPw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-calendar": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@types/react-calendar/-/react-calendar-3.4.5.tgz", + "integrity": "sha512-GAilbiUjVlD7RZzb6mxEMd80a2dbwpo8oFabvqmg1BLxs28xPxnvHZ+zCo38VztBnQwt3qHqgmyBcXbYczPN2w==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.1.tgz", + "integrity": "sha512-J6mYm43Sid9y+OjZ7NDfJ2VVkeeuTPNVImNFITgQNXodHteKfl/t/5pAR5Z9buodZ2tCctsZjgiMlQOpfntakw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-redux": { + "version": "7.1.22", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.22.tgz", + "integrity": "sha512-GxIA1kM7ClU73I6wg9IRTVwSO9GS+SAKZKe0Enj+82HMU6aoESFU2HNAdNi3+J53IaOHPiUfT3kSG4L828joDQ==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + }, + "dependencies": { + "@types/react": { + "version": "17.0.38", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.38.tgz", + "integrity": "sha512-SI92X1IA+FMnP3qM5m4QReluXzhcmovhZnLNm3pyeQlooi02qI7sLiepEYqT678uNiyc25XfCqxREFpy3W7YhQ==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "csstype": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + } + } + }, + "@types/react-router": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", + "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "@types/react-syntax-highlighter": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.5.tgz", + "integrity": "sha512-VIOi9i2Oj5XsmWWoB72p3KlZoEbdRAcechJa8Ztebw7bDl2YmR+odxIqhtJGp1q2EozHs02US+gzxJ9nuf56qg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-table": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.2.tgz", + "integrity": "sha512-NwB78t3YV5pZ1NK3m2vylb/d0DKVyWH4y4GMCtlE4tg2n5ENM4ejzKnT46YKuqG2cPjWc+PIxuRVMd5OYX1z4A==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-transition-group": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", + "integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-window": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.1.tgz", + "integrity": "sha512-V3k1O5cbfZIRa0VVbQ81Ekq/7w42CK1SuiB9U1oPMTxv270D9qUn7rHb3sZoqMkIJFfB1NZxaH7NRDlk+ToDsg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", + "dev": true + }, + "@types/slate": { + "version": "0.47.2", + "resolved": "https://registry.npmjs.org/@types/slate/-/slate-0.47.2.tgz", + "integrity": "sha512-1qwuAtuJ2Tnhewr2tz3OsDeM9xIC1ttTkBuSuf2P4Dy7K9I/I8HVeVowpo8k2vzVX7UMkc3OHo0RpC7V6pWCBw==", + "dev": true, + "requires": { + "@types/react": "*", + "immutable": "^3.8.2" + }, + "dependencies": { + "immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=", + "dev": true + } + } + }, + "@types/slate-react": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/@types/slate-react/-/slate-react-0.22.5.tgz", + "integrity": "sha512-WKJic5LlNRNUCnD6lEdlOZCcXWoDN8Ais2CmwVMn8pdt5Kh8hJsTYhXawNxOShPIOLVB+G+aVZNAXAAubEOpaw==", + "dev": true, + "requires": { + "@types/react": "*", + "@types/slate": "*", + "immutable": "^3.8.2" + }, + "dependencies": { + "immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=", + "dev": true + } + } + }, + "@types/source-list-map": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", + "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", + "dev": true + }, + "@types/tapable": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz", + "integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==", + "dev": true + }, + "@types/testing-library__react-hooks": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz", + "integrity": "sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==", + "dev": true, + "requires": { + "@types/react-test-renderer": "*" + } + }, + "@types/tinycolor2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.1.tgz", + "integrity": "sha512-25L/RL5tqZkquKXVHM1fM2bd23qjfbcPpAZ2N/H05Y45g3UEi+Hw8CbDV28shKY8gH1SHiLpZSxPI1lacqdpGg==", + "dev": true + }, + "@types/uglify-js": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.13.1.tgz", + "integrity": "sha512-O3MmRAk6ZuAKa9CHgg0Pr0+lUOqoMLpc9AS4R8ano2auvsg7IE8syF3Xh/NPr26TWklxYcqoEEFdzLLs1fV9PQ==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@types/unist": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", + "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", + "dev": true + }, + "@types/uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", + "dev": true + }, + "@types/webpack": { + "version": "4.41.32", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz", + "integrity": "sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/tapable": "^1", + "@types/uglify-js": "*", + "@types/webpack-sources": "*", + "anymatch": "^3.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@types/webpack-env": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.3.tgz", + "integrity": "sha512-9gtOPPkfyNoEqCQgx4qJKkuNm/x0R2hKR7fdl7zvTJyHnIisuE/LfvXOsYWL0o3qq6uiBnKZNNNzi3l0y/X+xw==", + "dev": true + }, + "@types/webpack-sources": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.0.tgz", + "integrity": "sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/source-list-map": "*", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "@types/yargs": { + "version": "15.0.14", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", + "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", + "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", + "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==", + "dev": true, + "requires": { + "@webassemblyjs/helper-module-context": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/wast-parser": "1.9.0" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz", + "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz", + "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz", + "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==", + "dev": true + }, + "@webassemblyjs/helper-code-frame": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz", + "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==", + "dev": true, + "requires": { + "@webassemblyjs/wast-printer": "1.9.0" + } + }, + "@webassemblyjs/helper-fsm": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz", + "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==", + "dev": true + }, + "@webassemblyjs/helper-module-context": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz", + "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0" + } + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + }, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "dev": true + } + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz", + "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz", + "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-buffer": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/wasm-gen": "1.9.0" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz", + "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz", + "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz", + "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz", + "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-buffer": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/helper-wasm-section": "1.9.0", + "@webassemblyjs/wasm-gen": "1.9.0", + "@webassemblyjs/wasm-opt": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0", + "@webassemblyjs/wast-printer": "1.9.0" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz", + "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/ieee754": "1.9.0", + "@webassemblyjs/leb128": "1.9.0", + "@webassemblyjs/utf8": "1.9.0" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz", + "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-buffer": "1.9.0", + "@webassemblyjs/wasm-gen": "1.9.0", + "@webassemblyjs/wasm-parser": "1.9.0" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz", + "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/helper-api-error": "1.9.0", + "@webassemblyjs/helper-wasm-bytecode": "1.9.0", + "@webassemblyjs/ieee754": "1.9.0", + "@webassemblyjs/leb128": "1.9.0", + "@webassemblyjs/utf8": "1.9.0" + } + }, + "@webassemblyjs/wast-parser": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz", + "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/floating-point-hex-parser": "1.9.0", + "@webassemblyjs/helper-api-error": "1.9.0", + "@webassemblyjs/helper-code-frame": "1.9.0", + "@webassemblyjs/helper-fsm": "1.9.0", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz", + "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.9.0", + "@webassemblyjs/wast-parser": "1.9.0", + "@xtuc/long": "4.2.2" + } + }, + "@webpack-contrib/schema-utils": { + "version": "1.0.0-beta.0", + "resolved": "https://registry.npmjs.org/@webpack-contrib/schema-utils/-/schema-utils-1.0.0-beta.0.tgz", + "integrity": "sha512-LonryJP+FxQQHsjGBi6W786TQB1Oym+agTpY0c+Kj8alnIw+DLUJb6SI8Y1GHGhLCH1yPRrucjObUmxNICQ1pg==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0", + "chalk": "^2.3.2", + "strip-ansi": "^4.0.0", + "text-table": "^0.2.0", + "webpack-log": "^1.1.2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + }, + "webpack-log": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-1.2.0.tgz", + "integrity": "sha512-U9AnICnu50HXtiqiDxuli5gLB5PGBo7VvcHx36jRZHwK4vzOYLbImqT4lwWwoMHdQWwEKw736fCHEekokTEKHA==", + "dev": true, + "requires": { + "chalk": "^2.1.0", + "log-symbols": "^2.1.0", + "loglevelnext": "^1.0.1", + "uuid": "^3.1.0" + } + } + } + }, + "@wojtekmaj/date-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-1.0.3.tgz", + "integrity": "sha512-1VPkkTBk07gMR1fjpBtse4G+oJqpmE+0gUFB0dg3VIL7qJmUVaBoD/vlzMm/jNeOPfvlmerl1lpnsZyBUFIRuw==" + }, + "@wojtekmaj/enzyme-adapter-react-17": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-react-17/-/enzyme-adapter-react-17-0.6.2.tgz", + "integrity": "sha512-9STl8ZKp8VPJgtr6jEAv9IThX0PAJ1JXKv6IlXzq22Ejyk1bhSxJ6RfJCJT9A9+8JlDIf3BX5CC4s0Bs4/1wFQ==", + "dev": true, + "requires": { + "@wojtekmaj/enzyme-adapter-utils": "^0.1.1", + "enzyme-shallow-equal": "^1.0.0", + "has": "^1.0.0", + "object.assign": "^4.1.0", + "object.values": "^1.1.0", + "prop-types": "^15.7.0", + "react-is": "^17.0.2", + "react-test-renderer": "^17.0.0" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, + "@wojtekmaj/enzyme-adapter-utils": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@wojtekmaj/enzyme-adapter-utils/-/enzyme-adapter-utils-0.1.2.tgz", + "integrity": "sha512-MM/DqDqvxNVlWLqSVQiUbRN9MuDLJfefmPbJ8ZKdmdf5ID8G+i42XhFpoQh5bAZUCdwzRae3+WSZl2lXcFOrhw==", + "dev": true, + "requires": { + "function.prototype.name": "^1.1.0", + "has": "^1.0.0", + "object.fromentries": "^2.0.0", + "prop-types": "^15.7.0" + } + }, + "@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==" + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true + }, + "acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true + }, + "add-dom-event-listener": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz", + "integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==", + "requires": { + "object-assign": "4.x" + } + }, + "add-px-to-style": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/add-px-to-style/-/add-px-to-style-1.0.0.tgz", + "integrity": "sha1-0ME1RB+oAUqBN5BFMQlvZ/KPJjo=" + }, + "address": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.1.2.tgz", + "integrity": "sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==", + "dev": true + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "airbnb-js-shims": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz", + "integrity": "sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==", + "dev": true, + "requires": { + "array-includes": "^3.0.3", + "array.prototype.flat": "^1.2.1", + "array.prototype.flatmap": "^1.2.1", + "es5-shim": "^4.5.13", + "es6-shim": "^0.35.5", + "function.prototype.name": "^1.1.0", + "globalthis": "^1.0.0", + "object.entries": "^1.1.0", + "object.fromentries": "^2.0.0 || ^1.0.0", + "object.getownpropertydescriptors": "^2.0.3", + "object.values": "^1.1.0", + "promise.allsettled": "^1.0.0", + "promise.prototype.finally": "^3.1.0", + "string.prototype.matchall": "^4.0.0 || ^3.0.1", + "string.prototype.padend": "^3.0.0", + "string.prototype.padstart": "^3.0.0", + "symbol.prototype.description": "^1.0.0" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-errors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", + "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", + "dev": true + }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz", + "integrity": "sha512-qOKJyNj/h+OWx7s5DePL6Zu1KeM9jPZhwBqs+7DzP6bGOvqzVCSf0xueYmVuaC/oQ/VtS2zLMLHdQFbkka+XDQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true + }, + "alphanum-sort": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", + "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", + "dev": true + }, + "ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "requires": { + "string-width": "^4.1.0" + } + }, + "ansi-colors": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz", + "integrity": "sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==", + "dev": true + }, + "ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansi-to-html": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.15.tgz", + "integrity": "sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==", + "dev": true, + "requires": { + "entities": "^2.0.0" + } + }, + "ansicolor": { + "version": "1.1.95", + "resolved": "https://registry.npmjs.org/ansicolor/-/ansicolor-1.1.95.tgz", + "integrity": "sha512-R4yTmrfQZ2H9Wr5TZoM2iOz0+T6TNHqztpld7ZToaN8EaUj/06NG4r5UHQfegA9/+K/OY3E+WumprcglbcTMRA==" + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "app-root-dir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", + "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=", + "dev": true + }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true + }, + "are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==" + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", + "dev": true + }, + "array-includes": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz", + "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" + } + }, + "array-tree-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==" + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "array.prototype.filter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.1.tgz", + "integrity": "sha512-Dk3Ty7N42Odk7PjU/Ci3zT4pLj20YvuVnneG/58ICM6bt4Ij5kZaJTVQ9TSaWaIECX2sFyz4KItkVZqHNnciqw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + } + }, + "array.prototype.flat": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", + "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0" + } + }, + "array.prototype.flatmap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.5.tgz", + "integrity": "sha512-08u6rVyi1Lj7oqWbS9nUxliETrtIROT4XGTA4D/LWGten6E3ocm7cy9SIrmNHOL5XVbVuckUp3X6Xyg8/zpvHA==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0" + } + }, + "array.prototype.map": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.4.tgz", + "integrity": "sha512-Qds9QnX7A0qISY7JT5WuJO0NJPE9CMlC6JzHQfhpqAAQQzufVRoeH7EzUY5GcPTx72voG8LV/5eo+b8Qi8hmhA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.7" + } + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "dev": true + }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", + "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "dev": true, + "requires": { + "tslib": "^2.0.1" + } + }, + "async-each": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", + "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "dev": true, + "optional": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + }, + "autoprefixer": { + "version": "9.8.8", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz", + "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==", + "dev": true, + "requires": { + "browserslist": "^4.12.0", + "caniuse-lite": "^1.0.30001109", + "normalize-range": "^0.1.2", + "num2fraction": "^1.2.2", + "picocolors": "^0.2.1", + "postcss": "^7.0.32", + "postcss-value-parser": "^4.1.0" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "axe-core": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz", + "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==", + "dev": true + }, + "babel-loader": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", + "integrity": "sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^1.4.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "dependencies": { + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + } + } + }, + "babel-plugin-add-react-displayname": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/babel-plugin-add-react-displayname/-/babel-plugin-add-react-displayname-0.0.5.tgz", + "integrity": "sha1-M51M3be2X9YtHfnbn+BN4TQSK9U=", + "dev": true + }, + "babel-plugin-apply-mdx-type-prop": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz", + "integrity": "sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "7.10.4", + "@mdx-js/util": "1.6.22" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + } + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "babel-plugin-emotion": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.2.2.tgz", + "integrity": "sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/serialize": "^0.11.16", + "babel-plugin-macros": "^2.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^1.0.5", + "find-root": "^1.1.0", + "source-map": "^0.5.7" + }, + "dependencies": { + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "dev": true + }, + "@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "dev": true, + "requires": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==", + "dev": true + }, + "csstype": { + "version": "2.6.20", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz", + "integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } + } + }, + "babel-plugin-extract-import-names": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz", + "integrity": "sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "7.10.4" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + } + } + }, + "babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + } + }, + "babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "requires": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "babel-plugin-named-asset-import": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", + "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "dev": true + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz", + "integrity": "sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.13.11", + "@babel/helper-define-polyfill-provider": "^0.3.1", + "semver": "^6.1.1" + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz", + "integrity": "sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.1", + "core-js-compat": "^3.21.0" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz", + "integrity": "sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.1" + } + }, + "babel-plugin-react-docgen": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/babel-plugin-react-docgen/-/babel-plugin-react-docgen-4.2.1.tgz", + "integrity": "sha512-UQ0NmGHj/HAqi5Bew8WvNfCk8wSsmdgNd8ZdMjBCICtyCJCq9LiqgqvjCYe570/Wg7AQArSq1VQ60Dd/CHN7mQ==", + "dev": true, + "requires": { + "ast-types": "^0.14.2", + "lodash": "^4.17.15", + "react-docgen": "^5.0.0" + } + }, + "babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=", + "dev": true + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==" + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + } + } + }, + "bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, + "batch-processor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz", + "integrity": "sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg=", + "dev": true + }, + "better-opn": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", + "integrity": "sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==", + "dev": true, + "requires": { + "open": "^7.0.3" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==", + "dev": true + }, + "body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.8.1", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.9.7", + "raw-body": "2.4.3", + "type-is": "~1.6.18" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", + "dev": true + } + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "dev": true, + "requires": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz", + "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==", + "dev": true, + "requires": { + "bn.js": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", + "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "dev": true, + "requires": { + "bn.js": "^5.1.1", + "browserify-rsa": "^4.0.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.5.3", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.5", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, + "browserslist": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", + "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001286", + "electron-to-chromium": "^1.4.17", + "escalade": "^3.1.1", + "node-releases": "^2.0.1", + "picocolors": "^1.0.0" + } + }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "c8": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-7.11.0.tgz", + "integrity": "sha512-XqPyj1uvlHMr+Y1IeRndC2X5P7iJzJlEJwBpCdBbq2JocXOgJfr+JVfJkyNMGROke5LfKrhSFXGFXnwnRJAUJw==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.2", + "find-up": "^5.0.0", + "foreground-child": "^2.0.0", + "istanbul-lib-coverage": "^3.0.1", + "istanbul-lib-report": "^3.0.0", + "istanbul-reports": "^3.0.2", + "rimraf": "^3.0.0", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^8.0.0", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.7" + }, + "dependencies": { + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + } + } + }, + "cacache": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz", + "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "calculate-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/calculate-size/-/calculate-size-1.1.1.tgz", + "integrity": "sha1-rnyqHHeV+CxPA13HvicONYHa4+4=" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "requires": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001300", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz", + "integrity": "sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA==", + "dev": true + }, + "capture-exit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", + "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", + "dev": true, + "requires": { + "rsvp": "^4.8.4" + } + }, + "case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", + "dev": true + }, + "ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + } + } + }, + "character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "dev": true + }, + "character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "dev": true + }, + "character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "dev": true + }, + "charcodes": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/charcodes/-/charcodes-0.2.0.tgz", + "integrity": "sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ==", + "dev": true + }, + "cheerio": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", + "integrity": "sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==", + "dev": true, + "requires": { + "cheerio-select": "^1.5.0", + "dom-serializer": "^1.3.2", + "domhandler": "^4.2.0", + "htmlparser2": "^6.1.0", + "parse5": "^6.0.1", + "parse5-htmlparser2-tree-adapter": "^6.0.1", + "tslib": "^2.2.0" + } + }, + "cheerio-select": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.5.0.tgz", + "integrity": "sha512-qocaHPv5ypefh6YNxvnbABM07KMxExbtbfuJoIie3iZXX1ERwYmJcIiRrr9H05ucQP1k28dav8rpdDgjQd8drg==", + "dev": true, + "requires": { + "css-select": "^4.1.3", + "css-what": "^5.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0", + "domutils": "^2.7.0" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, + "clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "dev": true + }, + "cli-table3": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", + "dev": true, + "requires": { + "colors": "1.4.0", + "string-width": "^4.2.0" + } + }, + "clipboard": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.4.tgz", + "integrity": "sha512-Vw26VSLRpJfBofiVaFb/I8PVfdI1OxKcYShe6fm0sP/DtmiWQNCjhM/okTvdCo0G+lMMm1rMYbk4IK4x1X+kgQ==", + "requires": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + }, + "collapse-white-space": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", + "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "colord": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz", + "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==", + "dev": true + }, + "colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "optional": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true + }, + "common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "component-classes": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/component-classes/-/component-classes-1.2.6.tgz", + "integrity": "sha1-xkI5TDYYpNiwuJGe/Mu9kw5c1pE=", + "requires": { + "component-indexof": "0.0.3" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "component-indexof": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-indexof/-/component-indexof-0.0.3.tgz", + "integrity": "sha1-EdCRMSI5648yyPJa6csAL/6NPCQ=" + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "compute-scroll-into-view": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz", + "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "requires": { + "safe-buffer": "5.2.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", + "dev": true + }, + "copy-concurrently": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", + "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "fs-write-stream-atomic": "^1.0.8", + "iferr": "^0.1.5", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.0" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "copy-to-clipboard": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz", + "integrity": "sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==", + "requires": { + "toggle-selection": "^1.0.6" + } + }, + "core-js": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.10.0.tgz", + "integrity": "sha512-MQx/7TLgmmDVamSyfE+O+5BHvG1aUGj/gHhLn1wVtm2B5u1eVIPvh7vkfjwWKNCjrTJB8+He99IntSQ1qP+vYQ==" + }, + "core-js-compat": { + "version": "3.21.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.21.1.tgz", + "integrity": "sha512-gbgX5AUvMb8gwxC7FLVWYT7Kkgu/y7+h/h1X43yJkNqhlK2fuYyQimqvKGNZFAY6CKii/GFKJ2cp/1/42TN36g==", + "dev": true, + "requires": { + "browserslist": "^4.19.1", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "core-js-pure": { + "version": "3.21.1", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.21.1.tgz", + "integrity": "sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ==", + "dev": true + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "cp-file": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-7.0.0.tgz", + "integrity": "sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "nested-error-stacks": "^2.0.0", + "p-event": "^4.1.0" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + } + } + }, + "cpy": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/cpy/-/cpy-8.1.2.tgz", + "integrity": "sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg==", + "dev": true, + "requires": { + "arrify": "^2.0.1", + "cp-file": "^7.0.0", + "globby": "^9.2.0", + "has-glob": "^1.0.0", + "junk": "^3.1.0", + "nested-error-stacks": "^2.1.0", + "p-all": "^2.1.0", + "p-filter": "^2.1.0", + "p-map": "^3.0.0" + }, + "dependencies": { + "globby": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz", + "integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^1.0.2", + "dir-glob": "^2.2.2", + "fast-glob": "^2.2.6", + "glob": "^7.1.3", + "ignore": "^4.0.3", + "pify": "^4.0.1", + "slash": "^2.0.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + } + } + }, + "create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "css-animation": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/css-animation/-/css-animation-1.6.1.tgz", + "integrity": "sha512-/48+/BaEaHRY6kNQ2OIPzKf9A6g8WjZYjhiNDNuIVbsm5tXCGIAsHDjB4Xu1C4vXJtUWZo26O68OQkDpNBaPog==", + "requires": { + "babel-runtime": "6.x", + "component-classes": "^1.2.5" + } + }, + "css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "requires": { + "tiny-invariant": "^1.0.6" + } + }, + "css-declaration-sorter": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.1.4.tgz", + "integrity": "sha512-lpfkqS0fctcmZotJGhnxkIyJWvBXgpyi2wsFd4J8VB7wzyrT6Ch/3Q+FMNJpjK4gu1+GN5khOnpU2ZVKrLbhCw==", + "dev": true, + "requires": { + "timsort": "^0.3.0" + } + }, + "css-in-js-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz", + "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==", + "requires": { + "hyphenate-style-name": "^1.0.2", + "isobject": "^3.0.1" + } + }, + "css-loader": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.4.0.tgz", + "integrity": "sha512-Dlt6qfsxI/w1vU0r8qDd4BtMPxWqJeY5qQU7SmmZfvbpe6Xl18McO4GhyaMLns24Y2VNPiZwJPQ8JSbg4qvQLw==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "semver": "^7.3.5" + }, + "dependencies": { + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dev": true, + "requires": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "ajv": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz", + "integrity": "sha512-qOKJyNj/h+OWx7s5DePL6Zu1KeM9jPZhwBqs+7DzP6bGOvqzVCSf0xueYmVuaC/oQ/VtS2zLMLHdQFbkka+XDQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "jest-worker": { + "version": "27.4.6", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.4.6.tgz", + "integrity": "sha512-gHWJF/6Xi5CTG5QCvROr6GcmpIqNYpDJyc8A1h/DyXqH1tD6SnRCM0d3U5msV31D2LB/U+E0M+W4oyvKV44oNw==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "css-select": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.2.1.tgz", + "integrity": "sha512-/aUslKhzkTNCQUB2qTX84lVmfia9NyjP3WpDGtj/WxhwBzWBYUV3DgUpurHTme8UTPcPlAD1DJ+b0nN/t50zDQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^5.1.0", + "domhandler": "^4.3.0", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + } + }, + "css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "requires": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, + "css-what": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz", + "integrity": "sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==", + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "cssnano": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.0.15.tgz", + "integrity": "sha512-ppZsS7oPpi2sfiyV5+i+NbB/3GtQ+ab2Vs1azrZaXWujUSN4o+WdTxlCZIMcT9yLW3VO/5yX3vpyDaQ1nIn8CQ==", + "dev": true, + "requires": { + "cssnano-preset-default": "^5.1.10", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + } + }, + "cssnano-preset-default": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.1.10.tgz", + "integrity": "sha512-BcpSzUVygHMOnp9uG5rfPzTOCb0GAHQkqtUQx8j1oMNF9A1Q8hziOOhiM4bdICpmrBIU85BE64RD5XGYsVQZNA==", + "dev": true, + "requires": { + "css-declaration-sorter": "^6.0.3", + "cssnano-utils": "^3.0.0", + "postcss-calc": "^8.2.0", + "postcss-colormin": "^5.2.3", + "postcss-convert-values": "^5.0.2", + "postcss-discard-comments": "^5.0.1", + "postcss-discard-duplicates": "^5.0.1", + "postcss-discard-empty": "^5.0.1", + "postcss-discard-overridden": "^5.0.2", + "postcss-merge-longhand": "^5.0.4", + "postcss-merge-rules": "^5.0.4", + "postcss-minify-font-values": "^5.0.2", + "postcss-minify-gradients": "^5.0.4", + "postcss-minify-params": "^5.0.3", + "postcss-minify-selectors": "^5.1.1", + "postcss-normalize-charset": "^5.0.1", + "postcss-normalize-display-values": "^5.0.2", + "postcss-normalize-positions": "^5.0.2", + "postcss-normalize-repeat-style": "^5.0.2", + "postcss-normalize-string": "^5.0.2", + "postcss-normalize-timing-functions": "^5.0.2", + "postcss-normalize-unicode": "^5.0.2", + "postcss-normalize-url": "^5.0.4", + "postcss-normalize-whitespace": "^5.0.2", + "postcss-ordered-values": "^5.0.3", + "postcss-reduce-initial": "^5.0.2", + "postcss-reduce-transforms": "^5.0.2", + "postcss-svgo": "^5.0.3", + "postcss-unique-selectors": "^5.0.2" + } + }, + "cssnano-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.0.0.tgz", + "integrity": "sha512-Pzs7/BZ6OgT+tXXuF12DKR8SmSbzUeVYCtMBbS8lI0uAm3mrYmkyqCXXPsQESI6kmLfEVBppbdVY/el3hg3nAA==", + "dev": true + }, + "csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "requires": { + "css-tree": "^1.1.2" + } + }, + "csstype": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + "dev": true + }, + "cyclist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", + "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=", + "dev": true + }, + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dev": true, + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, + "d3": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-5.15.0.tgz", + "integrity": "sha512-C+E80SL2nLLtmykZ6klwYj5rPqB5nlfN5LdWEAVdWPppqTD8taoJi2PxLZjPeYT8FFRR2yucXq+kBlOnnvZeLg==", + "requires": { + "d3-array": "1", + "d3-axis": "1", + "d3-brush": "1", + "d3-chord": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-contour": "1", + "d3-dispatch": "1", + "d3-drag": "1", + "d3-dsv": "1", + "d3-ease": "1", + "d3-fetch": "1", + "d3-force": "1", + "d3-format": "1", + "d3-geo": "1", + "d3-hierarchy": "1", + "d3-interpolate": "1", + "d3-path": "1", + "d3-polygon": "1", + "d3-quadtree": "1", + "d3-random": "1", + "d3-scale": "2", + "d3-scale-chromatic": "1", + "d3-selection": "1", + "d3-shape": "1", + "d3-time": "1", + "d3-time-format": "2", + "d3-timer": "1", + "d3-transition": "1", + "d3-voronoi": "1", + "d3-zoom": "1" + } + }, + "d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "d3-axis": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", + "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==" + }, + "d3-brush": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.6.tgz", + "integrity": "sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "d3-chord": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", + "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", + "requires": { + "d3-array": "1", + "d3-path": "1" + } + }, + "d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, + "d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "d3-contour": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", + "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", + "requires": { + "d3-array": "^1.1.1" + } + }, + "d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" + }, + "d3-drag": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", + "requires": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "d3-dsv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", + "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", + "requires": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + } + }, + "d3-ease": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", + "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==" + }, + "d3-fetch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz", + "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==", + "requires": { + "d3-dsv": "1" + } + }, + "d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" + }, + "d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "requires": { + "d3-array": "1" + } + }, + "d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==" + }, + "d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "requires": { + "d3-color": "1" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-polygon": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", + "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==" + }, + "d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" + }, + "d3-random": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", + "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==" + }, + "d3-scale": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", + "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", + "requires": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "requires": { + "d3-color": "1", + "d3-interpolate": "1" + } + }, + "d3-selection": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", + "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" + }, + "d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "requires": { + "d3-time": "1" + } + }, + "d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" + }, + "d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "d3-zoom": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", + "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "date-fns": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.25.0.tgz", + "integrity": "sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w==" + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", + "dev": true + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "deep-object-diff": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.7.tgz", + "integrity": "sha512-QkgBca0mL08P6HiOjoqvmm6xOAl2W6CT2+34Ljhg0OeFan8cwlcdq8jrLKsBBuUFAZLsN5b6y491KdKEoSo9lg==", + "dev": true + }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "des.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", + "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "detab": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detab/-/detab-2.0.4.tgz", + "integrity": "sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==", + "dev": true, + "requires": { + "repeat-string": "^1.5.4" + } + }, + "detect-port": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.3.0.tgz", + "integrity": "sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ==", + "dev": true, + "requires": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "diff-sequences": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.4.0.tgz", + "integrity": "sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww==", + "dev": true + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "dev": true, + "requires": { + "path-type": "^3.0.0" + }, + "dependencies": { + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "direction": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/direction/-/direction-0.1.5.tgz", + "integrity": "sha1-zl15f5fib4vnvv9T99xA4cGp7Ew=" + }, + "discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-accessibility-api": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz", + "integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==" + }, + "dom-align": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.2.tgz", + "integrity": "sha512-pHuazgqrsTFrGU2WLDdXxCFabkdQDx72ddkraZNih1KsMcN5qsRSTR9O4VJRlwTPCPb5COYg3LOfiMHHcPInHg==" + }, + "dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "requires": { + "utila": "~0.4" + } + }, + "dom-css": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/dom-css/-/dom-css-2.1.0.tgz", + "integrity": "sha1-/bwtWgFdCj4YcuEUcrvQ57nmogI=", + "requires": { + "add-px-to-style": "1.0.0", + "prefix-style": "2.0.1", + "to-camel-case": "1.0.0" + } + }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, + "dom-serializer": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", + "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", + "dev": true + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true + }, + "domhandler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.0.tgz", + "integrity": "sha512-fC0aXNQXqKSFTr2wDNZDhsEYjCiYsDWl3D01kwt25hm1YIPyDGHvvi3rw+PLqHAl/m71MaiF7d5zvBr0p5UB2g==", + "dev": true, + "requires": { + "domelementtype": "^2.2.0" + } + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true + }, + "dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true + }, + "downshift": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz", + "integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.14.8", + "compute-scroll-into-view": "^1.0.17", + "prop-types": "^15.7.2", + "react-is": "^17.0.2", + "tslib": "^2.3.0" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, + "duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "dev": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "electron-to-chromium": { + "version": "1.4.49", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.49.tgz", + "integrity": "sha512-k/0t1TRfonHIp8TJKfjBu2cKj8MqYTiEpOhci+q7CVEE5xnCQnx1pTa+V8b/sdhe4S3PR4p4iceEQWhGrKQORQ==", + "dev": true + }, + "element-resize-detector": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.2.4.tgz", + "integrity": "sha512-Fl5Ftk6WwXE0wqCgNoseKWndjzZlDCwuPTcoVZfCP9R3EHQF8qUtr3YUPNETegRBOKqQKPW3n4kiIWngGi8tKg==", + "dev": true, + "requires": { + "batch-processor": "1.0.0" + } + }, + "elliptic": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", + "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "dev": true, + "requires": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "emotion-theming": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emotion-theming/-/emotion-theming-10.3.0.tgz", + "integrity": "sha512-mXiD2Oj7N9b6+h/dC6oLf9hwxbtKHQjoIqtodEyL8CpkN4F3V4IK/BT4D0C7zSs4BBFOu4UlPJbvvBLa88SGEA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/weak-memoize": "0.2.5", + "hoist-non-react-statics": "^3.3.0" + } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "endent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/endent/-/endent-2.1.0.tgz", + "integrity": "sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==", + "dev": true, + "requires": { + "dedent": "^0.7.0", + "fast-json-parse": "^1.0.3", + "objectorarray": "^1.0.5" + } + }, + "enhanced-resolve": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz", + "integrity": "sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.5.0", + "tapable": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + }, + "enzyme": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", + "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", + "dev": true, + "requires": { + "array.prototype.flat": "^1.2.3", + "cheerio": "^1.0.0-rc.3", + "enzyme-shallow-equal": "^1.0.1", + "function.prototype.name": "^1.1.2", + "has": "^1.0.3", + "html-element-map": "^1.2.0", + "is-boolean-object": "^1.0.1", + "is-callable": "^1.1.5", + "is-number-object": "^1.0.4", + "is-regex": "^1.0.5", + "is-string": "^1.0.5", + "is-subset": "^0.1.1", + "lodash.escape": "^4.0.1", + "lodash.isequal": "^4.5.0", + "object-inspect": "^1.7.0", + "object-is": "^1.0.2", + "object.assign": "^4.1.0", + "object.entries": "^1.1.1", + "object.values": "^1.1.1", + "raf": "^3.4.1", + "rst-selector-parser": "^2.2.3", + "string.prototype.trim": "^1.2.1" + } + }, + "enzyme-shallow-equal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz", + "integrity": "sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object-is": "^1.1.2" + } + }, + "errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "error-stack-parser": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz", + "integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==", + "requires": { + "stackframe": "^1.1.1" + } + }, + "es-abstract": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", + "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.4", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.1", + "is-string": "^1.0.7", + "is-weakref": "^1.0.1", + "object-inspect": "^1.11.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + } + }, + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "es-get-iterator": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.2.tgz", + "integrity": "sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.0", + "has-symbols": "^1.0.1", + "is-arguments": "^1.1.0", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.5", + "isarray": "^2.0.5" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, + "es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", + "dev": true + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "es5-ext": { + "version": "0.10.53", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", + "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==", + "dev": true, + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.3", + "next-tick": "~1.0.0" + } + }, + "es5-shim": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.5.tgz", + "integrity": "sha512-vfQ4UAai8szn0sAubCy97xnZ4sJVDD1gt/Grn736hg8D7540wemIb1YPrYZSTqlM2H69EQX1or4HU/tSwRTI3w==", + "dev": true + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-shim": { + "version": "0.35.6", + "resolved": "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.6.tgz", + "integrity": "sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==", + "dev": true + }, + "es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "dev": true, + "requires": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "esrever": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/esrever/-/esrever-0.2.0.tgz", + "integrity": "sha1-lunSj08bGnZ4TNXUkOquAQ50B7g=" + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "estree-to-babel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/estree-to-babel/-/estree-to-babel-3.2.1.tgz", + "integrity": "sha512-YNF+mZ/Wu2FU/gvmzuWtYc8rloubL7wfXCTgouFrnjGVXPA/EeYYA7pupXWrb3Iv1cTBeSSxxJIbK23l4MRNqg==", + "dev": true, + "requires": { + "@babel/traverse": "^7.1.6", + "@babel/types": "^7.2.0", + "c8": "^7.6.0" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "exec-sh": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz", + "integrity": "sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==", + "dev": true + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=" + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "expose-loader": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-3.0.0.tgz", + "integrity": "sha512-X7ncrzmiQbJHOyLwuymECHk4NYvoPFwnsINMYFaRrm4fxuxR59hV1v65bho3TpIoWieP8WZmhz6micKny8orWg==", + "dev": true + }, + "express": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.3.tgz", + "integrity": "sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==", + "dev": true, + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.19.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.9.7", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.17.2", + "serve-static": "1.14.2", + "setprototypeof": "1.2.0", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "dev": true + }, + "qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", + "dev": true + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "ext": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", + "integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==", + "dev": true, + "requires": { + "type": "^2.5.0" + }, + "dependencies": { + "type": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz", + "integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-glob": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz", + "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.1.2", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.3", + "micromatch": "^3.1.10" + }, + "dependencies": { + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + } + } + }, + "fast-json-parse": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz", + "integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-shallow-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", + "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" + }, + "fastest-stable-stringify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", + "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==" + }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "dev": true, + "requires": { + "format": "^0.2.0" + } + }, + "fb-watchman": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", + "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, + "figgy-pudding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", + "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==", + "dev": true + }, + "file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "file-selector": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.2.4.tgz", + "integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==", + "requires": { + "tslib": "^2.0.3" + } + }, + "file-system-cache": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-1.0.5.tgz", + "integrity": "sha1-hCWbNqK7uNPW6xAh0xMv/mTP/08=", + "dev": true, + "requires": { + "bluebird": "^3.3.5", + "fs-extra": "^0.30.0", + "ramda": "^0.21.0" + }, + "dependencies": { + "fs-extra": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.30.0.tgz", + "integrity": "sha1-8jP/zAjU2n1DLapEl3aYnbHfk/A=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0", + "path-is-absolute": "^1.0.0", + "rimraf": "^2.2.8" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + } + } + }, + "find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "dependencies": { + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "flatted": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", + "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", + "dev": true + }, + "flush-write-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "fork-ts-checker-webpack-plugin": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz", + "integrity": "sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "chalk": "^2.4.1", + "micromatch": "^3.1.10", + "minimatch": "^3.0.4", + "semver": "^5.6.0", + "tapable": "^1.0.0", + "worker-rpc": "^0.1.0" + }, + "dependencies": { + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + } + } + }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=", + "dev": true + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "dev": true + }, + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "dependencies": { + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + } + } + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, + "fs-write-stream-atomic": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz", + "integrity": "sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "iferr": "^0.1.5", + "imurmurhash": "^0.1.4", + "readable-stream": "1 || 2" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "function.prototype.name": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", + "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0", + "functions-have-names": "^1.2.2" + } + }, + "functions-have-names": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.2.tgz", + "integrity": "sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==", + "dev": true + }, + "fuse.js": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz", + "integrity": "sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==", + "dev": true + }, + "gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "dev": true, + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + } + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-document": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-document/-/get-document-1.0.0.tgz", + "integrity": "sha1-SCG85m8cJMsDMWAr5strEsTwHEs=" + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, + "get-user-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-1.4.0.tgz", + "integrity": "sha512-gQo03lP1OArHLKlnoglqrGGl7b04u2EP9Xutmp72cMdtrrSD7ZgIsCsUKZynYWLDkVJW33Cj3pliP7uP0UonHQ==", + "requires": { + "lodash.once": "^4.1.1" + } + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "get-window": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/get-window/-/get-window-1.1.2.tgz", + "integrity": "sha512-yjWpFcy9fjhLQHW1dPtg9ga4pmizLY8y4ZSHdGrAQ1NU277MRhnGnnLPxe19X8W5lWVsCZz++5xEuNozWMVmTw==", + "requires": { + "get-document": "1" + } + }, + "github-slugger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.4.0.tgz", + "integrity": "sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==", + "dev": true + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-promise": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-3.4.0.tgz", + "integrity": "sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==", + "dev": true, + "requires": { + "@types/glob": "*" + } + }, + "glob-to-regexp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz", + "integrity": "sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=", + "dev": true + }, + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dev": true, + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globalthis": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.2.tgz", + "integrity": "sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "dependencies": { + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "fast-glob": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", + "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true + } + } + }, + "good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", + "requires": { + "delegate": "^3.1.2" + } + }, + "graceful-fs": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", + "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", + "dev": true + }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "has-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz", + "integrity": "sha1-mqqe7b/7G6OZCnsAEPtnjuAIEgc=", + "dev": true, + "requires": { + "is-glob": "^3.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", + "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "dev": true, + "requires": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hast-to-hyperscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", + "integrity": "sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==", + "dev": true, + "requires": { + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "property-information": "^5.3.0", + "space-separated-tokens": "^1.0.0", + "style-to-object": "^0.3.0", + "unist-util-is": "^4.0.0", + "web-namespaces": "^1.0.0" + } + }, + "hast-util-from-parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz", + "integrity": "sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==", + "dev": true, + "requires": { + "@types/parse5": "^5.0.0", + "hastscript": "^6.0.0", + "property-information": "^5.0.0", + "vfile": "^4.0.0", + "vfile-location": "^3.2.0", + "web-namespaces": "^1.0.0" + } + }, + "hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "dev": true + }, + "hast-util-raw": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-6.0.1.tgz", + "integrity": "sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==", + "dev": true, + "requires": { + "@types/hast": "^2.0.0", + "hast-util-from-parse5": "^6.0.0", + "hast-util-to-parse5": "^6.0.0", + "html-void-elements": "^1.0.0", + "parse5": "^6.0.0", + "unist-util-position": "^3.0.0", + "vfile": "^4.0.0", + "web-namespaces": "^1.0.0", + "xtend": "^4.0.0", + "zwitch": "^1.0.0" + } + }, + "hast-util-to-parse5": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz", + "integrity": "sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==", + "dev": true, + "requires": { + "hast-to-hyperscript": "^9.0.0", + "property-information": "^5.0.0", + "web-namespaces": "^1.0.0", + "xtend": "^4.0.0", + "zwitch": "^1.0.0" + } + }, + "hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "dev": true, + "requires": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "highlight-words-core": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/highlight-words-core/-/highlight-words-core-1.2.2.tgz", + "integrity": "sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==" + }, + "highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true + }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + }, + "dependencies": { + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + } + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "html-element-map": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", + "integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==", + "dev": true, + "requires": { + "array.prototype.filter": "^1.0.0", + "call-bind": "^1.0.2" + } + }, + "html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "html-minifier-terser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", + "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", + "dev": true, + "requires": { + "camel-case": "^4.1.1", + "clean-css": "^4.2.3", + "commander": "^4.1.1", + "he": "^1.2.0", + "param-case": "^3.0.3", + "relateurl": "^0.2.7", + "terser": "^4.6.3" + }, + "dependencies": { + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + } + } + }, + "html-tags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", + "integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==", + "dev": true + }, + "html-void-elements": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz", + "integrity": "sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==", + "dev": true + }, + "html-webpack-plugin": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz", + "integrity": "sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==", + "dev": true, + "requires": { + "@types/html-minifier-terser": "^5.0.0", + "@types/tapable": "^1.0.5", + "@types/webpack": "^4.41.8", + "html-minifier-terser": "^5.0.1", + "loader-utils": "^1.2.3", + "lodash": "^4.17.20", + "pretty-error": "^2.1.1", + "tapable": "^1.1.3", + "util.promisify": "1.0.0" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + } + } + }, + "htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "hyphenate-style-name": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "icss-utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz", + "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==", + "dev": true, + "requires": { + "postcss": "^7.0.14" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, + "iferr": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz", + "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", + "dev": true + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "dev": true + }, + "inline-style-prefixer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.1.tgz", + "integrity": "sha512-AsqazZ8KcRzJ9YPN1wMH2aNM7lkWQ8tSPrW5uDk1ziYwiAPWSZnUsC7lfZq+BDqLqz0B4Pho5wscWcJzVvRzDQ==", + "requires": { + "css-in-js-utils": "^2.0.0" + } + }, + "internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + } + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "intl-messageformat": { + "version": "9.11.2", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.11.2.tgz", + "integrity": "sha512-4wsinP2ObVK1Rz5C4121lgVeHeOCW32FOsqcVXtJNdlow+NypJKmnrije9rOc0bKxPwtto9IkXdgakXUmYXVHw==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.1", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.0.16", + "tslib": "^2.1.0" + } + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "dev": true + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true + }, + "is-absolute-url": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", + "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==", + "dev": true + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "dev": true + }, + "is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dev": true, + "requires": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + } + }, + "is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-callable": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", + "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", + "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "requires": { + "has": "^1.0.3" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "dev": true + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-dom": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-dom/-/is-dom-1.1.0.tgz", + "integrity": "sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==", + "dev": true, + "requires": { + "is-object": "^1.0.1", + "is-window": "^1.0.2" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "dev": true + }, + "is-hotkey": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.6.tgz", + "integrity": "sha512-1+hMr0GLPM0M49UDRt9RgE8i+SM29UY4AGRP6sGz6fThOVXqSrEvTMakolhHMcVizJnPNAoMpEmE+Oi1k2NrZQ==" + }, + "is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" + }, + "is-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", + "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-number-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", + "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "requires": { + "isobject": "^3.0.1" + } + }, + "is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-set": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", + "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true + }, + "is-shared-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", + "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=", + "dev": true + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-whitespace-character": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", + "integrity": "sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==", + "dev": true + }, + "is-window": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz", + "integrity": "sha1-LIlspT25feRdPDMTOmXYyfVjSA0=" + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is-word-character": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz", + "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==", + "dev": true + }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + }, + "isomorphic-base64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/isomorphic-base64/-/isomorphic-base64-1.0.2.tgz", + "integrity": "sha1-9Caq6CVpuopOxcpzrSGkSrHueAM=" + }, + "istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.1.0.tgz", + "integrity": "sha512-czwUz525rkOFDJxfKK6mYfIs9zBKILyrZQxjz3ABhjQXhbhFsSbo1HW/BFcsDnfJYJWA6thRR5/TUY2qs5W99Q==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-reports": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz", + "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "iterate-iterator": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz", + "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==", + "dev": true + }, + "iterate-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz", + "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==", + "dev": true, + "requires": { + "es-get-iterator": "^1.0.2", + "iterate-iterator": "^1.0.1" + } + }, + "jest-diff": { + "version": "27.4.6", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.4.6.tgz", + "integrity": "sha512-zjaB0sh0Lb13VyPsd92V7HkqF6yKRH9vm33rwBt7rPYrpQvS1nCvlIy2pICbKta+ZjWngYLNn4cCK4nyZkjS/w==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.4.0", + "jest-get-type": "^27.4.0", + "pretty-format": "^27.4.6" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-get-type": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.4.0.tgz", + "integrity": "sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ==", + "dev": true + }, + "jest-haste-map": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz", + "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.1.2", + "graceful-fs": "^4.2.4", + "jest-regex-util": "^26.0.0", + "jest-serializer": "^26.6.2", + "jest-util": "^26.6.2", + "jest-worker": "^26.6.2", + "micromatch": "^4.0.2", + "sane": "^4.0.3", + "walker": "^1.0.7" + } + }, + "jest-regex-util": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz", + "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==", + "dev": true + }, + "jest-serializer": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz", + "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==", + "dev": true, + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.4" + } + }, + "jest-util": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz", + "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/node": "*", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "is-ci": "^2.0.0", + "micromatch": "^4.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, + "js-string-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", + "integrity": "sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", + "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "junk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", + "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", + "dev": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, + "klona": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", + "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==", + "dev": true + }, + "lazy-universal-dotenv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-3.0.1.tgz", + "integrity": "sha512-prXSYk799h3GY3iOWnC6ZigYzMPjxN2svgjJ9shk7oMadSNX3wXy0B6F32PMJv7qtMnrIbUxoEHzbutvxR2LBQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.0", + "app-root-dir": "^1.0.2", + "core-js": "^3.0.4", + "dotenv": "^8.0.0", + "dotenv-expand": "^5.1.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lilconfig": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz", + "integrity": "sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "loader-runner": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", + "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==", + "dev": true + }, + "loader-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz", + "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "dev": true + }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "loglevelnext": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/loglevelnext/-/loglevelnext-1.0.5.tgz", + "integrity": "sha512-V/73qkPuJmx4BcBF19xPBr+0ZRVBhc4POxvZTZdMeXpJ4NItXSJ/MSwuFT0kQJlCbXvdlZoQQ/418bS1y9Jh6A==", + "dev": true, + "requires": { + "es6-symbol": "^3.1.1", + "object.assign": "^4.1.0" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "requires": { + "tslib": "^2.0.3" + } + }, + "lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "dev": true, + "requires": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=" + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "requires": { + "tmpl": "1.0.5" + } + }, + "map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "requires": { + "p-defer": "^1.0.0" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-or-similar": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", + "integrity": "sha1-beJlMXSt+12e3DPGnT6Sobdvrwg=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "markdown-escapes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", + "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==", + "dev": true + }, + "markdown-to-jsx": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.7.tgz", + "integrity": "sha512-VI3TyyHlGkO8uFle0IOibzpO1c1iJDcXcS/zBrQrXQQvJ2tpdwVzVZ7XdKsyRz1NdRmre4dqQkMZzUHaKIG/1w==", + "dev": true + }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "mdast-squeeze-paragraphs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz", + "integrity": "sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==", + "dev": true, + "requires": { + "unist-util-remove": "^2.0.0" + } + }, + "mdast-util-definitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", + "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==", + "dev": true, + "requires": { + "unist-util-visit": "^2.0.0" + } + }, + "mdast-util-to-hast": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz", + "integrity": "sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==", + "dev": true, + "requires": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + } + }, + "mdast-util-to-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz", + "integrity": "sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==", + "dev": true + }, + "mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "mem": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz", + "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.3", + "mimic-fn": "^3.1.0" + } + }, + "memfs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.1.tgz", + "integrity": "sha512-1c9VPVvW5P7I85c35zAdEr1TD5+F11IToIHIlrVIcflfnzPkJa0ZoYEoEdYDP8KgPFoSZ/opDrUsAoZWym3mtw==", + "dev": true, + "requires": { + "fs-monkey": "1.0.3" + } + }, + "memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "memoizerific": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", + "integrity": "sha1-fIekZGREwy11Q4VwkF8tvRsagFo=", + "dev": true, + "requires": { + "map-or-similar": "^1.5.0" + } + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "merge-class-names": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/merge-class-names/-/merge-class-names-1.4.2.tgz", + "integrity": "sha512-bOl98VzwCGi25Gcn3xKxnR5p/WrhWFQB59MS/aGENcmUc6iSm96yrFDF0XSNurX9qN4LbJm0R9kfvsQ17i8zCw==" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "microevent.ts": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz", + "integrity": "sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==", + "dev": true + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz", + "integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==", + "dev": true + }, + "mime-types": { + "version": "2.1.34", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz", + "integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==", + "dev": true, + "requires": { + "mime-db": "1.51.0" + } + }, + "mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "dev": true + }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", + "dev": true, + "requires": { + "dom-walk": "^0.1.0" + } + }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, + "mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", + "requires": { + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + }, + "dependencies": { + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + } + } + }, + "mini-svg-data-uri": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz", + "integrity": "sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "minipass": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", + "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mississippi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", + "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^3.0.0", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "requires": { + "minimist": "^1.2.6" + }, + "dependencies": { + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + } + } + }, + "mock-raf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mock-raf/-/mock-raf-1.0.1.tgz", + "integrity": "sha512-+25y56bblLzEnv+G4ODsHNck07A5uP5HFfu/1VBKeFrUXoFT9oru+R+jLxLz6rwdM5drUHFdqX9LYBsMP4dz/w==", + "dev": true, + "requires": { + "object-assign": "^3.0.0" + }, + "dependencies": { + "object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I=", + "dev": true + } + } + }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, + "moo": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", + "integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==", + "dev": true + }, + "move-concurrently": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", + "integrity": "sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "copy-concurrently": "^1.0.0", + "fs-write-stream-atomic": "^1.0.8", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.3" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "nan": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "dev": true, + "optional": true + }, + "nano-css": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.3.4.tgz", + "integrity": "sha512-wfcviJB6NOxDIDfr7RFn/GlaN7I/Bhe4d39ZRCJ3xvZX60LVe2qZ+rDqM49nm4YT81gAjzS+ZklhKP/Gnfnubg==", + "requires": { + "css-tree": "^1.1.2", + "csstype": "^3.0.6", + "fastest-stable-stringify": "^2.0.2", + "inline-style-prefixer": "^6.0.0", + "rtl-css-js": "^1.14.0", + "sourcemap-codec": "^1.4.8", + "stacktrace-js": "^2.0.2", + "stylis": "^4.0.6" + }, + "dependencies": { + "csstype": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + } + } + }, + "nanoid": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", + "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==", + "dev": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "requires": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + } + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "nested-error-stacks": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz", + "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==", + "dev": true + }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "requires": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node-dir": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", + "integrity": "sha1-X1Zl2TNRM1yqvvjxxVRRbPXx5OU=", + "dev": true, + "requires": { + "minimatch": "^3.0.2" + } + }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", + "dev": true + }, + "node-libs-browser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", + "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.1", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "^1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + } + } + }, + "node-releases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", + "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "dev": true, + "requires": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "nth-check": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", + "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-inspect": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", + "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", + "dev": true + }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.entries": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", + "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "object.fromentries": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz", + "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz", + "integrity": "sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "object.values": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", + "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "objectorarray": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/objectorarray/-/objectorarray-1.0.5.tgz", + "integrity": "sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "dependencies": { + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + } + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "overlayscrollbars": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-1.13.1.tgz", + "integrity": "sha512-gIQfzgGgu1wy80EB4/6DaJGHMEGmizq27xHIESrzXq0Y/J0Ay1P3DWk6tuVmEPIZH15zaBlxeEJOqdJKmowHCQ==", + "dev": true + }, + "p-all": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-all/-/p-all-2.1.0.tgz", + "integrity": "sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==", + "dev": true, + "requires": { + "p-map": "^2.0.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", + "dev": true + }, + "p-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", + "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", + "dev": true, + "requires": { + "p-timeout": "^3.1.0" + } + }, + "p-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", + "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", + "dev": true, + "requires": { + "p-map": "^2.0.0" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "papaparse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.0.tgz", + "integrity": "sha512-Lb7jN/4bTpiuGPrYy4tkKoUS8sTki8zacB5ke1p5zolhcSE4TlWgrlsxjrDTbG/dFVh07ck7X36hUf/b5V68pg==" + }, + "parallel-transform": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", + "dev": true, + "requires": { + "cyclist": "^1.0.1", + "inherits": "^2.0.3", + "readable-stream": "^2.1.5" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "requires": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-asn1": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz", + "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==", + "dev": true, + "requires": { + "asn1.js": "^5.2.0", + "browserify-aes": "^1.0.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3", + "safe-buffer": "^5.1.1" + } + }, + "parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "dev": true, + "requires": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "requires": { + "parse5": "^6.0.1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + }, + "pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pirates": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", + "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "dev": true + }, + "pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "requires": { + "find-up": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + } + } + }, + "pnp-webpack-plugin": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz", + "integrity": "sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==", + "dev": true, + "requires": { + "ts-pnp": "^1.1.6" + } + }, + "polished": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", + "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.17.8" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz", + "integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "8.3.11", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.11.tgz", + "integrity": "sha512-hCmlUAIlUiav8Xdqw3Io4LcpA1DOt7h3LSTAC4G6JGHFFaWzI6qvFt9oilvl8BmkbBRX1IhM90ZAmpk68zccQA==", + "dev": true, + "requires": { + "nanoid": "^3.1.30", + "picocolors": "^1.0.0", + "source-map-js": "^0.6.2" + } + }, + "postcss-calc": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.2.tgz", + "integrity": "sha512-B5R0UeB4zLJvxNt1FVCaDZULdzsKLPc6FhjFJ+xwFiq7VG4i9cuaJLxVjNtExNK8ocm3n2o4unXXLiVX1SCqxA==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.0.2" + } + }, + "postcss-colormin": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.2.3.tgz", + "integrity": "sha512-dra4xoAjub2wha6RUXAgadHEn2lGxbj8drhFcIGLOMn914Eu7DkPUurugDXgstwttCYkJtZ/+PkWRWdp3UHRIA==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-convert-values": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.0.2.tgz", + "integrity": "sha512-KQ04E2yadmfa1LqXm7UIDwW1ftxU/QWZmz6NKnHnUvJ3LEYbbcX6i329f/ig+WnEByHegulocXrECaZGLpL8Zg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-discard-comments": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.0.1.tgz", + "integrity": "sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg==", + "dev": true + }, + "postcss-discard-duplicates": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.1.tgz", + "integrity": "sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA==", + "dev": true + }, + "postcss-discard-empty": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.0.1.tgz", + "integrity": "sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw==", + "dev": true + }, + "postcss-discard-overridden": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.0.2.tgz", + "integrity": "sha512-+56BLP6NSSUuWUXjRgAQuho1p5xs/hU5Sw7+xt9S3JSg+7R6+WMGnJW7Hre/6tTuZ2xiXMB42ObkiZJ2hy/Pew==", + "dev": true + }, + "postcss-flexbugs-fixes": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-4.2.1.tgz", + "integrity": "sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==", + "dev": true, + "requires": { + "postcss": "^7.0.26" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-loader": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.1.1.tgz", + "integrity": "sha512-lBmJMvRh1D40dqpWKr9Rpygwxn8M74U9uaCSeYGNKLGInbk9mXBt1ultHf2dH9Ghk6Ue4UXlXWwGMH9QdUJ5ug==", + "dev": true, + "requires": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.4", + "semver": "^7.3.5" + }, + "dependencies": { + "cosmiconfig": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", + "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "postcss-merge-longhand": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.0.4.tgz", + "integrity": "sha512-2lZrOVD+d81aoYkZDpWu6+3dTAAGkCKbV5DoRhnIR7KOULVrI/R7bcMjhrH9KTRy6iiHKqmtG+n/MMj1WmqHFw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.1.0", + "stylehacks": "^5.0.1" + } + }, + "postcss-merge-rules": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.0.4.tgz", + "integrity": "sha512-yOj7bW3NxlQxaERBB0lEY1sH5y+RzevjbdH4DBJurjKERNpknRByFNdNe+V72i5pIZL12woM9uGdS5xbSB+kDQ==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.0.0", + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-minify-font-values": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.0.2.tgz", + "integrity": "sha512-R6MJZryq28Cw0AmnyhXrM7naqJZZLoa1paBltIzh2wM7yb4D45TLur+eubTQ4jCmZU9SGeZdWsc5KcSoqTMeTg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-gradients": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.0.4.tgz", + "integrity": "sha512-RVwZA7NC4R4J76u8X0Q0j+J7ItKUWAeBUJ8oEEZWmtv3Xoh19uNJaJwzNpsydQjk6PkuhRrK+YwwMf+c+68EYg==", + "dev": true, + "requires": { + "colord": "^2.9.1", + "cssnano-utils": "^3.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-params": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.0.3.tgz", + "integrity": "sha512-NY92FUikE+wralaiVexFd5gwb7oJTIDhgTNeIw89i1Ymsgt4RWiPXfz3bg7hDy4NL6gepcThJwOYNtZO/eNi7Q==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.2", + "browserslist": "^4.16.6", + "cssnano-utils": "^3.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.1.1.tgz", + "integrity": "sha512-TOzqOPXt91O2luJInaVPiivh90a2SIK5Nf1Ea7yEIM/5w+XA5BGrZGUSW8aEx9pJ/oNj7ZJBhjvigSiBV+bC1Q==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.2", + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-modules-extract-imports": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz", + "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==", + "dev": true, + "requires": { + "postcss": "^7.0.5" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-local-by-default": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz", + "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==", + "dev": true, + "requires": { + "icss-utils": "^4.1.1", + "postcss": "^7.0.32", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-scope": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz", + "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==", + "dev": true, + "requires": { + "postcss": "^7.0.6", + "postcss-selector-parser": "^6.0.0" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-values": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz", + "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==", + "dev": true, + "requires": { + "icss-utils": "^4.0.0", + "postcss": "^7.0.6" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-normalize-charset": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.0.1.tgz", + "integrity": "sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg==", + "dev": true + }, + "postcss-normalize-display-values": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.2.tgz", + "integrity": "sha512-RxXoJPUR0shSjkMMzgEZDjGPrgXUVYyWA/YwQRicb48H15OClPuaDR7tYokLAlGZ2tCSENEN5WxjgxSD5m4cUw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-positions": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.0.2.tgz", + "integrity": "sha512-tqghWFVDp2btqFg1gYob1etPNxXLNh3uVeWgZE2AQGh6b2F8AK2Gj36v5Vhyh+APwIzNjmt6jwZ9pTBP+/OM8g==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-repeat-style": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.0.2.tgz", + "integrity": "sha512-/rIZn8X9bBzC7KvY4iKUhXUGW3MmbXwfPF23jC9wT9xTi7kAvgj8sEgwxjixBmoL6MVa4WOgxNz2hAR6wTK8tw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-string": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.0.2.tgz", + "integrity": "sha512-zaI1yzwL+a/FkIzUWMQoH25YwCYxi917J4pYm1nRXtdgiCdnlTkx5eRzqWEC64HtRa06WCJ9TIutpb6GmW4gFw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-timing-functions": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.0.2.tgz", + "integrity": "sha512-Ao0PP6MoYsRU1LxeVUW740ioknvdIUmfr6uAA3xWlQJ9s69/Tupy8qwhuKG3xWfl+KvLMAP9p2WXF9cwuk/7Bg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-unicode": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.0.2.tgz", + "integrity": "sha512-3y/V+vjZ19HNcTizeqwrbZSUsE69ZMRHfiiyLAJb7C7hJtYmM4Gsbajy7gKagu97E8q5rlS9k8FhojA8cpGhWw==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-url": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.0.4.tgz", + "integrity": "sha512-cNj3RzK2pgQQyNp7dzq0dqpUpQ/wYtdDZM3DepPmFjCmYIfceuD9VIAcOdvrNetjIU65g1B4uwdP/Krf6AFdXg==", + "dev": true, + "requires": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-whitespace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.0.2.tgz", + "integrity": "sha512-CXBx+9fVlzSgbk0IXA/dcZn9lXixnQRndnsPC5ht3HxlQ1bVh77KQDL1GffJx1LTzzfae8ftMulsjYmO2yegxA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-ordered-values": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.0.3.tgz", + "integrity": "sha512-T9pDS+P9bWeFvqivXd5ACzQmrCmHjv3ZP+djn8E1UZY7iK79pFSm7i3WbKw2VSmFmdbMm8sQ12OPcNpzBo3Z2w==", + "dev": true, + "requires": { + "cssnano-utils": "^3.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-reduce-initial": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.0.2.tgz", + "integrity": "sha512-v/kbAAQ+S1V5v9TJvbGkV98V2ERPdU6XvMcKMjqAlYiJ2NtsHGlKYLPjWWcXlaTKNxooId7BGxeraK8qXvzKtw==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.0.2.tgz", + "integrity": "sha512-25HeDeFsgiPSUx69jJXZn8I06tMxLQJJNF5h7i9gsUg8iP4KOOJ8EX8fj3seeoLt3SLU2YDD6UPnDYVGUO7DEA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.8.tgz", + "integrity": "sha512-D5PG53d209Z1Uhcc0qAZ5U3t5HagH3cxu+WLZ22jt3gLUpXM4eXXfiO14jiDWST3NNooX/E8wISfOhZ9eIjGTQ==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-svgo": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.0.3.tgz", + "integrity": "sha512-41XZUA1wNDAZrQ3XgWREL/M2zSw8LJPvb5ZWivljBsUQAGoEKMYm6okHsTjJxKYI4M75RQEH4KYlEM52VwdXVA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.1.0", + "svgo": "^2.7.0" + } + }, + "postcss-unique-selectors": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.0.2.tgz", + "integrity": "sha512-w3zBVlrtZm7loQWRPVC0yjUwwpty7OM6DnEHkxcSQXO1bMS3RJ+JUS5LFMSDZHJcvGsRwhZinCWVqn8Kej4EDA==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.2", + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "prefix-style": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/prefix-style/-/prefix-style-2.0.1.tgz", + "integrity": "sha1-ZrupqHDP2jCKXcIOhekSCTLJWgY=" + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "prettier": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.0.tgz", + "integrity": "sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==", + "dev": true + }, + "pretty-error": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.2.tgz", + "integrity": "sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==", + "dev": true, + "requires": { + "lodash": "^4.17.20", + "renderkid": "^2.0.4" + } + }, + "pretty-format": { + "version": "27.4.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.4.6.tgz", + "integrity": "sha512-NblstegA1y/RJW2VyML+3LlpFjzx62cUrtBIKIWDXEDkjNeleA7Od7nrzcs/VLQvAeV4CgSYhrN39DRN88Qi/g==", + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, + "pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true + }, + "prismjs": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz", + "integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==" + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", + "dev": true + }, + "promise.allsettled": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.5.tgz", + "integrity": "sha512-tVDqeZPoBC0SlzJHzWGZ2NKAguVq2oiYj7gbggbiTvH2itHohijTp7njOUA0aQ/nl+0lr/r6egmhoYu63UZ/pQ==", + "dev": true, + "requires": { + "array.prototype.map": "^1.0.4", + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "iterate-value": "^1.0.2" + } + }, + "promise.prototype.finally": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/promise.prototype.finally/-/promise.prototype.finally-3.1.3.tgz", + "integrity": "sha512-EXRF3fC9/0gz4qkt/f5EP5iW4kj9oFpBICNpCNOb/52+8nlHIX07FPLbi/q4qYBQ1xZqivMzTpNQSnArVASolQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "dev": true, + "requires": { + "xtend": "^4.0.0" + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "requires": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + }, + "dependencies": { + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "qs": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", + "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "requires": { + "performance-now": "^2.1.0" + } + }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, + "railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", + "dev": true + }, + "ramda": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.21.0.tgz", + "integrity": "sha1-oAGr7bP/YQd9T/HVd9RN536NCjU=", + "dev": true + }, + "randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "requires": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + } + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.3.tgz", + "integrity": "sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "http-errors": "1.8.1", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "raw-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", + "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "rc-align": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.11.tgz", + "integrity": "sha512-n9mQfIYQbbNTbefyQnRHZPWuTEwG1rY4a9yKlIWHSTbgwI+XUMGRYd0uJ5pE2UbrNX0WvnMBA1zJ3Lrecpra/A==", + "requires": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "dom-align": "^1.7.0", + "lodash": "^4.17.21", + "rc-util": "^5.3.0", + "resize-observer-polyfill": "^1.5.1" + } + }, + "rc-animate": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/rc-animate/-/rc-animate-2.11.1.tgz", + "integrity": "sha512-1NyuCGFJG/0Y+9RKh5y/i/AalUCA51opyyS/jO2seELpgymZm2u9QV3xwODwEuzkmeQ1BDPxMLmYLcTJedPlkQ==", + "requires": { + "babel-runtime": "6.x", + "classnames": "^2.2.6", + "css-animation": "^1.3.2", + "prop-types": "15.x", + "raf": "^3.4.0", + "rc-util": "^4.15.3", + "react-lifecycles-compat": "^3.0.4" + }, + "dependencies": { + "rc-util": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.21.1.tgz", + "integrity": "sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==", + "requires": { + "add-dom-event-listener": "^1.1.0", + "prop-types": "^15.5.10", + "react-is": "^16.12.0", + "react-lifecycles-compat": "^3.0.4", + "shallowequal": "^1.1.0" + } + } + } + }, + "rc-cascader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-1.5.0.tgz", + "integrity": "sha512-A8GbWSStZdHMvqNq1ETGM0oX+hwOGQd0EQ10IoJ1qz8aZ5XazMfkZYAH64DPBEAtuHtXbyTemcknmSsUwLZmGw==", + "requires": { + "@babel/runtime": "^7.12.5", + "array-tree-filter": "^2.1.0", + "rc-trigger": "^5.0.4", + "rc-util": "^5.0.1", + "warning": "^4.0.1" + } + }, + "rc-drawer": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-4.4.0.tgz", + "integrity": "sha512-y0M6+RnZfYrSd0kuezRUKOL+9iTea3h/0eqDT+M2xhEVwCnqesp70efSuRNTW6FHK1vymQdOT7WSYdFt/+lOHw==", + "requires": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.7.0" + } + }, + "rc-motion": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.4.4.tgz", + "integrity": "sha512-ms7n1+/TZQBS0Ydd2Q5P4+wJTSOrhIrwNxLXCZpR7Fa3/oac7Yi803HDALc2hLAKaCTQtw9LmQeB58zcwOsqlQ==", + "requires": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.2.1" + } + }, + "rc-slider": { + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.6.4.tgz", + "integrity": "sha512-TgmfOh1Jj9fHsW2eDeldFy4mOKwyHn4krl4K6gqbXhGFND7jVRdfA0m7TDUx3gGeX7A17fM1QZymMO/NRJmoLw==", + "requires": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-tooltip": "^5.0.1", + "rc-util": "^5.0.0", + "shallowequal": "^1.1.0" + } + }, + "rc-time-picker": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/rc-time-picker/-/rc-time-picker-3.7.3.tgz", + "integrity": "sha512-Lv1Mvzp9fRXhXEnRLO4nW6GLNxUkfAZ3RsiIBsWjGjXXvMNjdr4BX/ayElHAFK0DoJqOhm7c5tjmIYpEOwcUXg==", + "requires": { + "classnames": "2.x", + "moment": "2.x", + "prop-types": "^15.5.8", + "raf": "^3.4.1", + "rc-trigger": "^2.2.0", + "react-lifecycles-compat": "^3.0.4" + }, + "dependencies": { + "rc-align": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-2.4.5.tgz", + "integrity": "sha512-nv9wYUYdfyfK+qskThf4BQUSIadeI/dCsfaMZfNEoxm9HwOIioQ+LyqmMK6jWHAZQgOzMLaqawhuBXlF63vgjw==", + "requires": { + "babel-runtime": "^6.26.0", + "dom-align": "^1.7.0", + "prop-types": "^15.5.8", + "rc-util": "^4.0.4" + } + }, + "rc-trigger": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-2.6.5.tgz", + "integrity": "sha512-m6Cts9hLeZWsTvWnuMm7oElhf+03GOjOLfTuU0QmdB9ZrW7jR2IpI5rpNM7i9MvAAlMAmTx5Zr7g3uu/aMvZAw==", + "requires": { + "babel-runtime": "6.x", + "classnames": "^2.2.6", + "prop-types": "15.x", + "rc-align": "^2.4.0", + "rc-animate": "2.x", + "rc-util": "^4.4.0", + "react-lifecycles-compat": "^3.0.4" + } + }, + "rc-util": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.21.1.tgz", + "integrity": "sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==", + "requires": { + "add-dom-event-listener": "^1.1.0", + "prop-types": "^15.5.10", + "react-is": "^16.12.0", + "react-lifecycles-compat": "^3.0.4", + "shallowequal": "^1.1.0" + } + } + } + }, + "rc-tooltip": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.1.1.tgz", + "integrity": "sha512-alt8eGMJulio6+4/uDm7nvV+rJq9bsfxFDCI0ljPdbuoygUscbsMYb6EQgwib/uqsXQUvzk+S7A59uYHmEgmDA==", + "requires": { + "@babel/runtime": "^7.11.2", + "rc-trigger": "^5.0.0" + } + }, + "rc-trigger": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.2.10.tgz", + "integrity": "sha512-FkUf4H9BOFDaIwu42fvRycXMAvkttph9AlbCZXssZDVzz2L+QZ0ERvfB/4nX3ZFPh1Zd+uVGr1DEDeXxq4J1TA==", + "requires": { + "@babel/runtime": "^7.11.2", + "classnames": "^2.2.6", + "rc-align": "^4.0.0", + "rc-motion": "^2.0.0", + "rc-util": "^5.5.0" + } + }, + "rc-util": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.16.1.tgz", + "integrity": "sha512-kSCyytvdb3aRxQacS/71ta6c+kBWvM1v8/2h9d/HaNWauc3qB8pLnF20PJ8NajkNN8gb+rR1l0eWO+D4Pz+LLQ==", + "requires": { + "@babel/runtime": "^7.12.5", + "react-is": "^16.12.0", + "shallowequal": "^1.1.0" + } + }, + "react": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz", + "integrity": "sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "react-beautiful-dnd": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", + "integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==", + "requires": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "dependencies": { + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + } + } + }, + "react-calendar": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-3.4.0.tgz", + "integrity": "sha512-ykbvXASArQQ8KZlCEXEdE+w+KFGSj7kDpdTRzbLVJeN26oUIu+EFavFDAqg4G//zTO0tSq5SMfqAa6fjrdGQjQ==", + "requires": { + "@wojtekmaj/date-utils": "^1.0.2", + "get-user-locale": "^1.2.0", + "merge-class-names": "^1.1.1", + "prop-types": "^15.6.0" + } + }, + "react-colorful": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.1.2.tgz", + "integrity": "sha512-FRt9jz6xjiPqQ6bIAQ26kd0oJhHbGBwsA4BDz/F8FDCFuQJDiEl0wVUARNiqRyvQjwfKuhM42P/bMYI0l92hRw==" + }, + "react-custom-scrollbars-2": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-custom-scrollbars-2/-/react-custom-scrollbars-2-4.4.0.tgz", + "integrity": "sha512-I+oxZ9rfHfvYm85jdH2lQqpzwNr/ZAdYB8htm6R/hwRGoIEK31jq+YE6MmFwBzuO7C5zcAtH5HN9vwZxnW61NQ==", + "requires": { + "dom-css": "^2.0.0", + "prop-types": "^15.5.10", + "raf": "^3.1.0" + } + }, + "react-docgen": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.0.tgz", + "integrity": "sha512-JBjVQ9cahmNlfjMGxWUxJg919xBBKAoy3hgDgKERbR+BcF4ANpDuzWAScC7j27hZfd8sJNmMPOLWo9+vB/XJEQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@babel/generator": "^7.12.11", + "@babel/runtime": "^7.7.6", + "ast-types": "^0.14.2", + "commander": "^2.19.0", + "doctrine": "^3.0.0", + "estree-to-babel": "^3.1.0", + "neo-async": "^2.6.1", + "node-dir": "^0.1.10", + "strip-indent": "^3.0.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.9.tgz", + "integrity": "sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.9", + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.9", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/types": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", + "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + } + } + }, + "react-docgen-typescript": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz", + "integrity": "sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==", + "dev": true + }, + "react-docgen-typescript-loader": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/react-docgen-typescript-loader/-/react-docgen-typescript-loader-3.7.2.tgz", + "integrity": "sha512-fNzUayyUGzSyoOl7E89VaPKJk9dpvdSgyXg81cUkwy0u+NBvkzQG3FC5WBIlXda0k/iaxS+PWi+OC+tUiGxzPA==", + "dev": true, + "requires": { + "@webpack-contrib/schema-utils": "^1.0.0-beta.0", + "loader-utils": "^1.2.3", + "react-docgen-typescript": "^1.15.0" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + }, + "react-docgen-typescript": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-1.22.0.tgz", + "integrity": "sha512-MPLbF8vzRwAG3GcjdL+OHQlhgtWsLTXs+7uJiHfEeT3Ur7IsZaNYqRTLQ9sj2nB6M6jylcPCeCmH7qbszJmecg==", + "dev": true + } + } + }, + "react-dom": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz", + "integrity": "sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.1" + } + }, + "react-draggable": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.4.tgz", + "integrity": "sha512-6e0WdcNLwpBx/YIDpoyd2Xb04PB0elrDrulKUgdrIlwuYvxh5Ok9M+F8cljm8kPXXs43PmMzek9RrB1b7mLMqA==", + "dev": true, + "requires": { + "clsx": "^1.1.1", + "prop-types": "^15.6.0" + } + }, + "react-dropzone": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-11.3.4.tgz", + "integrity": "sha512-B1nzNRZ4F1cnrfEC0T6KXeBN1mCPinu4JCoTrp7NjB+442KSPxqfDrw41QIA2kAwlYs1+wj/0BTedeM5hc2+xw==", + "requires": { + "attr-accept": "^2.2.1", + "file-selector": "^0.2.2", + "prop-types": "^15.7.2" + } + }, + "react-element-to-jsx-string": { + "version": "14.3.4", + "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz", + "integrity": "sha512-t4ZwvV6vwNxzujDQ+37bspnLwA4JlgUPWhLjBJWsNIDceAf6ZKUTCjdm08cN6WeZ5pTMKiCJkmAYnpmR4Bm+dg==", + "dev": true, + "requires": { + "@base2/pretty-print-object": "1.0.1", + "is-plain-object": "5.0.0", + "react-is": "17.0.2" + }, + "dependencies": { + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "react-from-dom": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/react-from-dom/-/react-from-dom-0.6.1.tgz", + "integrity": "sha512-7aAZx7LhRnmR51W5XtmTBYHGFl2n1AdEk1uoXLuzHa1OoGXrxOW/iwLcudvgp6BGX/l4Yh1rtMrIzvhlvbVddg==" + }, + "react-helmet-async": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.2.3.tgz", + "integrity": "sha512-mCk2silF53Tq/YaYdkl2sB+/tDoPnaxN7dFS/6ZLJb/rhUY2EWGI5Xj2b4jHppScMqY45MbgPSwTxDchKpZ5Kw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + } + }, + "react-highlight-words": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-highlight-words/-/react-highlight-words-0.17.0.tgz", + "integrity": "sha512-uX1Qh5IGjnLuJT0Zok234QDwRC8h4hcVMnB99Cb7aquB1NlPPDiWKm0XpSZOTdSactvnClCk8LOmVlP+75dgHA==", + "requires": { + "highlight-words-core": "^1.2.0", + "memoize-one": "^4.0.0", + "prop-types": "^15.5.8" + }, + "dependencies": { + "memoize-one": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.3.tgz", + "integrity": "sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==" + } + } + }, + "react-hook-form": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.5.3.tgz", + "integrity": "sha512-5T0mfJ4kCPKljd7t3Rgp7lML4Y2+kaZIeMdN6Zo/J7gBQ+WkrDBHOftdOtz4X+7/eqHGak5yL5evNpYdA9abVA==" + }, + "react-immutable-proptypes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz", + "integrity": "sha512-Vf4gBsePlwdGvSZoLSBfd4HAP93HDauMY4fDjXhreg/vg6F3Fj/MXDNyTbltPC/xZKmZc+cjLu3598DdYK6sgQ==", + "requires": { + "invariant": "^2.2.2" + } + }, + "react-inlinesvg": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-inlinesvg/-/react-inlinesvg-2.3.0.tgz", + "integrity": "sha512-fEGOdDf4k4bcveArbEpj01pJcH8pOCKLxmSj2POFdGvEk5YK0NZVnH6BXpW/PzACHPRsuh1YKAhNZyFnD28oxg==", + "requires": { + "exenv": "^1.2.2", + "react-from-dom": "^0.6.0" + } + }, + "react-input-autosize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-3.0.0.tgz", + "integrity": "sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg==", + "dev": true, + "requires": { + "prop-types": "^15.5.8" + } + }, + "react-inspector": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz", + "integrity": "sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.0.0", + "is-dom": "^1.0.0", + "prop-types": "^15.0.0" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-popper": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.4.tgz", + "integrity": "sha512-NacOu4zWupdQjVXq02XpTD3yFPSfg5a7fex0wa3uGKVkFK7UN6LvVxgcb+xYr56UCuWiNPMH20tntdVdJRwYew==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + } + }, + "react-popper-tooltip": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-3.1.1.tgz", + "integrity": "sha512-EnERAnnKRptQBJyaee5GJScWNUKQPDD2ywvzZyUjst/wj5U64C8/CnSYLNEmP2hG0IJ3ZhtDxE8oDN+KOyavXQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@popperjs/core": "^2.5.4", + "react-popper": "^2.2.4" + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", + "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", + "dev": true + } + } + }, + "react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, + "react-refresh": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "dev": true + }, + "react-router": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz", + "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "dependencies": { + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + } + } + }, + "react-router-dom": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz", + "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.2.1", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "dependencies": { + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + } + } + }, + "react-select-event": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-select-event/-/react-select-event-5.3.0.tgz", + "integrity": "sha512-Novkl7X9JJKmDV5LyYaKwl0vffWtqPrBa1vuI0v43P/f87mSA7JfdYxU93SFb99RssphVzBSIAbcnbX1w21QIQ==", + "requires": { + "@testing-library/dom": ">=7" + }, + "dependencies": { + "@testing-library/dom": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.2.tgz", + "integrity": "sha512-idsS/cqbYudXcVWngc1PuWNmXs416oBy2g/7Q8QAUREt5Z3MUkAL2XJD7xazLJ6esDfqRDi/ZBxk+OPPXitHRw==", + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "react-shallow-renderer": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz", + "integrity": "sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0" + } + }, + "react-sizeme": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/react-sizeme/-/react-sizeme-3.0.2.tgz", + "integrity": "sha512-xOIAOqqSSmKlKFJLO3inBQBdymzDuXx4iuwkNcJmC96jeiOg5ojByvL+g3MW9LPEsojLbC6pf68zOfobK8IPlw==", + "dev": true, + "requires": { + "element-resize-detector": "^1.2.2", + "invariant": "^2.2.4", + "shallowequal": "^1.1.0", + "throttle-debounce": "^3.0.1" + } + }, + "react-syntax-highlighter": { + "version": "13.5.3", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-13.5.3.tgz", + "integrity": "sha512-crPaF+QGPeHNIblxxCdf2Lg936NAHKhNhuMzRL3F9ct6aYXL3NcZtCL0Rms9+qVo6Y1EQLdXGypBNSbPL/r+qg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "highlight.js": "^10.1.1", + "lowlight": "^1.14.0", + "prismjs": "^1.21.0", + "refractor": "^3.1.0" + } + }, + "react-table": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz", + "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==" + }, + "react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-/dRae3mj6aObwkjCcxZPlxDFh73XZLgvwhhyON2haZGUEhiaY5EjfAdw+d/rQmlcFwdTpMXCSGVk374QbCTlrA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^17.0.1", + "react-shallow-renderer": "^16.13.1", + "scheduler": "^0.20.1" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, + "react-textarea-autosize": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz", + "integrity": "sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2", + "use-composed-ref": "^1.0.0", + "use-latest": "^1.0.0" + } + }, + "react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + } + } + }, + "react-universal-interface": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", + "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==" + }, + "react-use": { + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.2.4.tgz", + "integrity": "sha512-vQGpsAM0F5UIlshw5UI8ULGPS4yn5rm7/qvn3T1Gnkrz7YRMEEMh+ynKcmRloOyiIeLvKWiQjMiwRGtdbgs5qQ==", + "requires": { + "@types/js-cookie": "^2.2.6", + "@xobotyi/scrollbar-width": "^1.9.5", + "copy-to-clipboard": "^3.3.1", + "fast-deep-equal": "^3.1.3", + "fast-shallow-equal": "^1.0.0", + "js-cookie": "^2.2.1", + "nano-css": "^5.3.1", + "react-universal-interface": "^0.6.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.1.0", + "set-harmonic-interval": "^1.0.1", + "throttle-debounce": "^3.0.1", + "ts-easing": "^0.2.0", + "tslib": "^2.1.0" + } + }, + "react-window": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz", + "integrity": "sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==", + "requires": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "dependencies": { + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + } + } + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "dev": true, + "requires": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "dependencies": { + "prismjs": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", + "dev": true + } + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz", + "integrity": "sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==", + "dev": true, + "requires": { + "regenerate": "^1.4.2" + } + }, + "regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" + }, + "regenerator-transform": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", + "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexp.prototype.flags": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz", + "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "regexpu-core": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz", + "integrity": "sha512-CriEZlrKK9VJw/xQGJpQM5rY88BtuL8DM+AEwvcThHilbxiTAy8vq4iJnd2tqq8wLmjbGZzP7ZcKFjbGkmEFrw==", + "dev": true, + "requires": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.0.1", + "regjsgen": "^0.6.0", + "regjsparser": "^0.8.2", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.0.0" + } + }, + "regjsgen": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.6.0.tgz", + "integrity": "sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==", + "dev": true + }, + "regjsparser": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.8.4.tgz", + "integrity": "sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", + "dev": true + }, + "remark-external-links": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", + "integrity": "sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==", + "dev": true, + "requires": { + "extend": "^3.0.0", + "is-absolute-url": "^3.0.0", + "mdast-util-definitions": "^4.0.0", + "space-separated-tokens": "^1.0.0", + "unist-util-visit": "^2.0.0" + } + }, + "remark-footnotes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/remark-footnotes/-/remark-footnotes-2.0.0.tgz", + "integrity": "sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==", + "dev": true + }, + "remark-mdx": { + "version": "1.6.22", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-1.6.22.tgz", + "integrity": "sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==", + "dev": true, + "requires": { + "@babel/core": "7.12.9", + "@babel/helper-plugin-utils": "7.10.4", + "@babel/plugin-proposal-object-rest-spread": "7.12.1", + "@babel/plugin-syntax-jsx": "7.12.1", + "@mdx-js/util": "1.6.22", + "is-alphabetical": "1.0.4", + "remark-parse": "8.0.3", + "unified": "9.2.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz", + "integrity": "sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-transform-parameters": "^7.12.1" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "remark-parse": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz", + "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==", + "dev": true, + "requires": { + "ccount": "^1.0.0", + "collapse-white-space": "^1.0.2", + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "is-word-character": "^1.0.0", + "markdown-escapes": "^1.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "trim": "0.0.1", + "trim-trailing-lines": "^1.0.0", + "unherit": "^1.0.4", + "unist-util-remove-position": "^2.0.0", + "vfile-location": "^3.0.0", + "xtend": "^4.0.1" + } + }, + "remark-slug": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/remark-slug/-/remark-slug-6.1.0.tgz", + "integrity": "sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==", + "dev": true, + "requires": { + "github-slugger": "^1.0.0", + "mdast-util-to-string": "^1.0.0", + "unist-util-visit": "^2.0.0" + } + }, + "remark-squeeze-paragraphs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz", + "integrity": "sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==", + "dev": true, + "requires": { + "mdast-squeeze-paragraphs": "^4.0.0" + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "renderkid": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.7.tgz", + "integrity": "sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==", + "dev": true, + "requires": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, + "resolve": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.21.0.tgz", + "integrity": "sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==", + "requires": { + "is-core-module": "^2.8.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.1.tgz", + "integrity": "sha512-IQ4ikL8SjBiEDZfk+DFVwqRK8md24RWMEJkdSlgNLkyyAImcjf8SWvU1qFMDOb4igBClbTQ/ugPqXcRwdFTxZw==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "rollup": { + "version": "2.58.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.58.3.tgz", + "integrity": "sha512-ei27MSw1KhRur4p87Q0/Va2NAYqMXOX++FNEumMBcdreIRLURKy+cE2wcDJKBn0nfmhP2ZGrJkP1XPO+G8FJQw==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "rollup-plugin-sourcemaps": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/rollup-plugin-sourcemaps/-/rollup-plugin-sourcemaps-0.6.3.tgz", + "integrity": "sha512-paFu+nT1xvuO1tPFYXGe+XnQvg4Hjqv/eIhG8i5EspfYYPBKL57X7iVbfv55aNVASg3dzWvES9dmWsL2KhfByw==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.9", + "source-map-resolve": "^0.6.0" + }, + "dependencies": { + "source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + } + } + }, + "rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "terser": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz", + "integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.20" + } + } + } + }, + "rst-selector-parser": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", + "integrity": "sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=", + "dev": true, + "requires": { + "lodash.flattendeep": "^4.4.0", + "nearley": "^2.7.10" + } + }, + "rsvp": { + "version": "4.8.5", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", + "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", + "dev": true + }, + "rtl-css-js": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.15.0.tgz", + "integrity": "sha512-99Cu4wNNIhrI10xxUaABHsdDqzalrSRTie4GeCmbGVuehm4oj+fIy8fTzB+16pmKe8Bv9rl+hxIBez6KxExTew==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "run-queue": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz", + "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=", + "dev": true, + "requires": { + "aproba": "^1.1.1" + }, + "dependencies": { + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + } + } + }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" + }, + "rxjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.3.0.tgz", + "integrity": "sha512-p2yuGIg9S1epc3vrjKf6iVb3RCaAYjYskkO+jHIaV0IjOPlJop4UnodOoFb2xeNwlguqLYvGw1b1McillYb5Gw==", + "requires": { + "tslib": "~2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sane": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", + "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", + "dev": true, + "requires": { + "@cnakazawa/watch": "^1.0.3", + "anymatch": "^2.0.0", + "capture-exit": "^2.0.0", + "exec-sh": "^0.3.2", + "execa": "^1.0.0", + "fb-watchman": "^2.0.0", + "micromatch": "^3.1.4", + "minimist": "^1.1.1", + "walker": "~1.0.5" + }, + "dependencies": { + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + } + } + }, + "sass-loader": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.1.0.tgz", + "integrity": "sha512-FVJZ9kxVRYNZTIe2xhw93n3xJNYZADr+q69/s98l9nTCrWASo+DR2Ot0s5xTKQDDEosUkatsGeHxcH4QBp5bSg==", + "dev": true, + "requires": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + } + }, + "scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==" + }, + "select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=" + }, + "selection-is-backward": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/selection-is-backward/-/selection-is-backward-1.0.0.tgz", + "integrity": "sha1-l6VGMxiKURq6ZBn8XB+pG0Z+a+E=" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "send": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", + "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "1.8.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + } + } + }, + "serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-favicon": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz", + "integrity": "sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=", + "dev": true, + "requires": { + "etag": "~1.8.1", + "fresh": "0.5.2", + "ms": "2.1.1", + "parseurl": "~1.3.2", + "safe-buffer": "5.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "dev": true + } + } + }, + "serve-static": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.2.tgz", + "integrity": "sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.2" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-harmonic-interval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", + "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==" + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slate": { + "version": "0.47.8", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.47.8.tgz", + "integrity": "sha512-/Jt0eq4P40qZvtzeKIvNb+1N97zVICulGQgQoMDH0TI8h8B+5kqa1YeckRdRnuvfYJm3J/9lWn2V3J1PrF+hag==", + "requires": { + "debug": "^3.1.0", + "direction": "^0.1.5", + "esrever": "^0.2.0", + "is-plain-object": "^2.0.4", + "lodash": "^4.17.4", + "tiny-invariant": "^1.0.1", + "tiny-warning": "^0.0.3", + "type-of": "^2.0.1" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "slate-base64-serializer": { + "version": "0.2.115", + "resolved": "https://registry.npmjs.org/slate-base64-serializer/-/slate-base64-serializer-0.2.115.tgz", + "integrity": "sha512-GnLV7bUW/UQ5j7rVIxCU5zdB6NOVsEU6YWsCp68dndIjSGTGLaQv2+WwV3NcnrGGZEYe5qgo33j2QWrPws2C1A==", + "requires": { + "isomorphic-base64": "^1.0.2" + } + }, + "slate-dev-environment": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/slate-dev-environment/-/slate-dev-environment-0.2.5.tgz", + "integrity": "sha512-oLD8Fclv/RqrDv6RYfN2CRzNcRXsUB99Qgcw5L/njTjxAdDPguV6edQ3DgUG9Q2pLFLhI15DwsKClzVfFzfwGQ==", + "requires": { + "is-in-browser": "^1.1.3" + } + }, + "slate-hotkeys": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/slate-hotkeys/-/slate-hotkeys-0.2.11.tgz", + "integrity": "sha512-xhq/TlI74dRbO57O4ulGsvCcV4eaQ5nEEz9noZjeNLtNzFRd6lSgExRqAJqKGGIeJw+FnJ3OcqGvdb5CEc9/Ew==", + "requires": { + "is-hotkey": "0.1.4", + "slate-dev-environment": "^0.2.2" + }, + "dependencies": { + "is-hotkey": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.4.tgz", + "integrity": "sha512-Py+aW4r5mBBY18TGzGz286/gKS+fCQ0Hee3qkaiSmEPiD0PqFpe0wuA3l7rTOUKyeXl8Mxf3XzJxIoTlSv+kxA==" + } + } + }, + "slate-plain-serializer": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/slate-plain-serializer/-/slate-plain-serializer-0.7.10.tgz", + "integrity": "sha512-/QvMCQ0F3NzbnuoW+bxsLIChPdRgxBjQeGhYhpRGTVvlZCLOmfDvavhN6fHsuEwkvdwOmocNF30xT1WVlmibYg==" + }, + "slate-prop-types": { + "version": "0.5.44", + "resolved": "https://registry.npmjs.org/slate-prop-types/-/slate-prop-types-0.5.44.tgz", + "integrity": "sha512-JS0iW7uaciE/W3ADuzeN1HOnSjncQhHPXJ65nZNQzB0DF7mXVmbwQKI6cmCo/xKni7XRJT0JbWSpXFhEdPiBUA==" + }, + "slate-react-placeholder": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/slate-react-placeholder/-/slate-react-placeholder-0.2.9.tgz", + "integrity": "sha512-YSJ9Gb4tGpbzPje3eNKtu26hWM8ApxTk9RzjK+6zfD5V/RMTkuWONk24y6c9lZk0OAYNZNUmrnb/QZfU3j9nag==" + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "source-map-js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz", + "integrity": "sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "dev": true + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" + }, + "space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "dev": true + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz", + "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "ssri": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz", + "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==", + "dev": true, + "requires": { + "figgy-pudding": "^3.5.1" + } + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "stack-generator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz", + "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==", + "requires": { + "stackframe": "^1.1.1" + } + }, + "stackframe": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.0.tgz", + "integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA==" + }, + "stacktrace-gps": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz", + "integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==", + "requires": { + "source-map": "0.5.6", + "stackframe": "^1.1.1" + }, + "dependencies": { + "source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=" + } + } + }, + "stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "requires": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } + }, + "state-toggle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz", + "integrity": "sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==", + "dev": true + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "dev": true + }, + "store2": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/store2/-/store2-2.13.2.tgz", + "integrity": "sha512-CMtO2Uneg3SAz/d6fZ/6qbqqQHi2ynq6/KzMD/26gTkiEShCcpqFfTHgOxsE0egAq6SX3FmN4CeSqn8BzXQkJg==", + "dev": true + }, + "storybook-dark-mode": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.8.tgz", + "integrity": "sha512-uY6yTSd1vYE0YwlON50u+iIoNF/fmMj59ww1cpd/naUcmOmCjwawViKFG5YjichwdR/yJ5ybWRUF0tnRQfaSiw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.0.0", + "memoizerific": "^1.11.3" + } + }, + "stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "stream-each": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", + "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "stream-shift": "^1.0.0" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "string.prototype.matchall": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", + "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.3", + "regexp.prototype.flags": "^1.4.1", + "side-channel": "^1.0.4" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + } + } + }, + "string.prototype.padend": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz", + "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "string.prototype.padstart": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/string.prototype.padstart/-/string.prototype.padstart-3.1.3.tgz", + "integrity": "sha512-NZydyOMtYxpTjGqp0VN5PYUF/tsU15yDMZnUdj16qRUIUiMJkHHSDElYyQFrMu+/WloTpA7MQSiADhBicDfaoA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "string.prototype.trim": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.5.tgz", + "integrity": "sha512-Lnh17webJVsD6ECeovpVN17RlAKjmz4rF9S+8Y45CkMc/ufVpTkU3vZIyIC7sllQ1FCvObZnnCdNs/HXTUOTlg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, + "style-loader": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.0.tgz", + "integrity": "sha512-szANub7ksJtQioJYtpbWwh1hUl99uK15n5HDlikeCRil/zYMZgSxucHddyF/4A3qJMUiAjPhFowrrQuNMA7jwQ==", + "dev": true + }, + "style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "dev": true, + "requires": { + "inline-style-parser": "0.1.1" + } + }, + "stylehacks": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.0.1.tgz", + "integrity": "sha512-Es0rVnHIqbWzveU1b24kbw92HsebBepxfcqe5iix7t9j0PQqhs0IxXVXv0pY2Bxa08CgMkzD6OWql7kbGOuEdA==", + "dev": true, + "requires": { + "browserslist": "^4.16.0", + "postcss-selector-parser": "^6.0.4" + } + }, + "stylis": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz", + "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "requires": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + } + } + }, + "symbol.prototype.description": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/symbol.prototype.description/-/symbol.prototype.description-1.0.5.tgz", + "integrity": "sha512-x738iXRYsrAt9WBhRCVG5BtIC3B7CUkFwbHW2zOvGtwM33s7JjrCDyq8V0zgMYVb5ymsL8+qkzzpANH63CPQaQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-symbol-description": "^1.0.0", + "has-symbols": "^1.0.2", + "object.getownpropertydescriptors": "^2.1.2" + } + }, + "synchronous-promise": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.15.tgz", + "integrity": "sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg==", + "dev": true + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + }, + "tar": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + } + } + }, + "telejson": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/telejson/-/telejson-5.3.3.tgz", + "integrity": "sha512-PjqkJZpzEggA9TBpVtJi1LVptP7tYtXB6rEubwlHap76AMjzvOdKX41CxyaW7ahhzDU1aftXnMCx5kAPDZTQBA==", + "dev": true, + "requires": { + "@types/is-function": "^1.0.0", + "global": "^4.4.0", + "is-function": "^1.0.2", + "is-regex": "^1.1.2", + "is-symbol": "^1.0.3", + "isobject": "^4.0.0", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3" + }, + "dependencies": { + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true + } + } + }, + "terser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", + "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.2.4.tgz", + "integrity": "sha512-E2CkNMN+1cho04YpdANyRrn8CyN4yMy+WdFKZIySFZrGXZxJwJP6PMNGGc/Mcr6qygQHUUqRxnAPmi0M9f00XA==", + "dev": true, + "requires": { + "jest-worker": "^27.0.6", + "p-limit": "^3.1.0", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1", + "terser": "^5.7.2" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "jest-worker": { + "version": "27.4.6", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.4.6.tgz", + "integrity": "sha512-gHWJF/6Xi5CTG5QCvROr6GcmpIqNYpDJyc8A1h/DyXqH1tD6SnRCM0d3U5msV31D2LB/U+E0M+W4oyvKV44oNw==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "terser": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz", + "integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + } + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "throttle-debounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", + "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==" + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, + "tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" + }, + "tiny-warning": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-0.0.3.tgz", + "integrity": "sha512-r0SSA5Y5IWERF9Xh++tFPx0jITBgGggOsRLDWWew6YRw/C2dr4uNO1fw1vanrBmHsICmPyMLNBZboTlxUmUuaA==" + }, + "tinycolor2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", + "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=" + }, + "tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-camel-case": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-camel-case/-/to-camel-case-1.0.0.tgz", + "integrity": "sha1-GlYFSy+daWKYzmamCJcyK29CPkY=", + "requires": { + "to-space-case": "^1.0.0" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, + "to-no-case": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz", + "integrity": "sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo=" + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "to-space-case": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz", + "integrity": "sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc=", + "requires": { + "to-no-case": "^1.0.0" + } + }, + "toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "dev": true + }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=", + "dev": true + }, + "trim-trailing-lines": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz", + "integrity": "sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==", + "dev": true + }, + "trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "dev": true + }, + "ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true + }, + "ts-easing": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", + "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==" + }, + "ts-loader": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-8.0.11.tgz", + "integrity": "sha512-06X+mWA2JXoXJHYAesUUL4mHFYhnmyoCdQVMXofXF552Lzd4wNwSGg7unJpttqUP7ziaruM8d7u8LUB6I1sgzA==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "enhanced-resolve": "^4.0.0", + "loader-utils": "^1.0.2", + "micromatch": "^4.0.0", + "semver": "^6.0.0" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + } + } + } + }, + "ts-pnp": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", + "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==", + "dev": true + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "type-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-of/-/type-of-2.0.1.tgz", + "integrity": "sha1-5yoXQYllaOn2KDeNgW1pEvfyOXI=" + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "typescript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.3.tgz", + "integrity": "sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==", + "dev": true + }, + "uglify-js": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.3.tgz", + "integrity": "sha512-6iCVm2omGJbsu3JWac+p6kUiOpg3wFO2f8lIXjfEb8RrmLjzog1wTPMmwKB7swfzzqxj9YM+sGUM++u1qN4qJg==", + "dev": true, + "optional": true + }, + "unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + } + }, + "unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", + "dev": true + }, + "unherit": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", + "integrity": "sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==", + "dev": true, + "requires": { + "inherits": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", + "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz", + "integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==", + "dev": true + }, + "unified": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz", + "integrity": "sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==", + "dev": true, + "requires": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true + } + } + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "unist-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", + "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==", + "dev": true + }, + "unist-util-generated": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz", + "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==", + "dev": true + }, + "unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "dev": true + }, + "unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "dev": true + }, + "unist-util-remove": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-2.1.0.tgz", + "integrity": "sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==", + "dev": true, + "requires": { + "unist-util-is": "^4.0.0" + } + }, + "unist-util-remove-position": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz", + "integrity": "sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==", + "dev": true, + "requires": { + "unist-util-visit": "^2.0.0" + } + }, + "unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dev": true, + "requires": { + "@types/unist": "^2.0.2" + } + }, + "unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dev": true, + "requires": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + } + }, + "unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dev": true, + "requires": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + } + } + }, + "upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "optional": true + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, + "url-loader": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", + "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.27", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "use-composed-ref": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.2.1.tgz", + "integrity": "sha512-6+X1FLlIcjvFMAeAD/hcxDT8tmyrWnbSPMU0EnxQuDLIxokuFzWliXBiYZuGIx+mrAMLBw0WFfCkaPw8ebzAhw==", + "dev": true + }, + "use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "dev": true + }, + "use-latest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.0.tgz", + "integrity": "sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw==", + "dev": true, + "requires": { + "use-isomorphic-layout-effect": "^1.0.0" + } + }, + "use-memo-one": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz", + "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==" + }, + "util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "util.promisify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", + "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "object.getownpropertydescriptors": "^2.0.3" + } + }, + "utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true + }, + "uuid": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==" + }, + "uuid-browser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid-browser/-/uuid-browser-3.1.0.tgz", + "integrity": "sha1-DwWkCu90+eWVHiDvv0SxGHHlZBA=", + "dev": true + }, + "v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true + }, + "vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "dev": true, + "requires": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true + } + } + }, + "vfile-location": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-3.2.0.tgz", + "integrity": "sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==", + "dev": true + }, + "vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "dev": true, + "requires": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + } + }, + "vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true + }, + "walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "requires": { + "makeerror": "1.0.12" + } + }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "watchpack": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz", + "integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==", + "dev": true, + "requires": { + "chokidar": "^3.4.1", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0", + "watchpack-chokidar2": "^2.0.1" + } + }, + "watchpack-chokidar2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz", + "integrity": "sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==", + "dev": true, + "optional": true, + "requires": { + "chokidar": "^2.1.8" + }, + "dependencies": { + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "optional": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "optional": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } + } + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true, + "optional": true + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "optional": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "chokidar": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "dev": true, + "optional": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "optional": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "optional": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "optional": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "optional": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true, + "optional": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "optional": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "optional": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "optional": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + } + } + }, + "web-namespaces": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", + "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "dev": true + }, + "webpack": { + "version": "5.58.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.58.1.tgz", + "integrity": "sha512-4Z/dmbTU+VmkCb2XNgW7wkE5TfEcSooclprn/UEuVeAkwHhn07OcgUsyaKHGtCY/VobjnsYBlyhKeMLiSoOqPg==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.0", + "@types/estree": "^0.0.50", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.4.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.8.3", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.4", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.2.0", + "webpack-sources": "^3.2.0" + }, + "dependencies": { + "@types/estree": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", + "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "acorn": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", + "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz", + "integrity": "sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "loader-runner": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", + "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "dev": true + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "watchpack": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", + "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + } + } + }, + "webpack-dev-middleware": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz", + "integrity": "sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==", + "dev": true, + "requires": { + "memory-fs": "^0.4.1", + "mime": "^2.4.4", + "mkdirp": "^0.5.1", + "range-parser": "^1.2.1", + "webpack-log": "^2.0.0" + }, + "dependencies": { + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + } + } + }, + "webpack-filter-warnings-plugin": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz", + "integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==", + "dev": true + }, + "webpack-hot-middleware": { + "version": "2.25.1", + "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.1.tgz", + "integrity": "sha512-Koh0KyU/RPYwel/khxbsDz9ibDivmUbrRuKSSQvW42KSDdO4w23WI3SkHpSUKHE76LrFnnM/L7JCrpBwu8AXYw==", + "dev": true, + "requires": { + "ansi-html-community": "0.0.8", + "html-entities": "^2.1.0", + "querystring": "^0.2.0", + "strip-ansi": "^6.0.0" + } + }, + "webpack-log": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz", + "integrity": "sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==", + "dev": true, + "requires": { + "ansi-colors": "^3.0.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } + } + }, + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "webpack-virtual-modules": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.2.2.tgz", + "integrity": "sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==", + "dev": true, + "requires": { + "debug": "^3.0.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "requires": { + "string-width": "^4.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "worker-farm": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", + "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==", + "dev": true, + "requires": { + "errno": "~0.1.7" + } + }, + "worker-rpc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/worker-rpc/-/worker-rpc-0.1.1.tgz", + "integrity": "sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==", + "dev": true, + "requires": { + "microevent.ts": "~0.1.1" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "dev": true + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "dependencies": { + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + }, + "zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "dev": true + } + } +} diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 11e8ad900d69c..cc1a0a49d65fd 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/ui", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Grafana Components Library", "keywords": [ "grafana", @@ -33,9 +33,9 @@ "@emotion/css": "11.1.3", "@emotion/react": "11.1.5", "@grafana/aws-sdk": "0.0.3", - "@grafana/data": "8.3.0-pre", - "@grafana/e2e-selectors": "8.3.0-pre", - "@grafana/schema": "8.3.0-pre", + "@grafana/data": "8.3.10", + "@grafana/e2e-selectors": "8.3.10", + "@grafana/schema": "8.3.10", "@grafana/slate-react": "0.22.10-grafana", "@monaco-editor/react": "4.2.2", "@popperjs/core": "2.5.4", @@ -87,7 +87,7 @@ "slate-plain-serializer": "0.7.10", "tinycolor2": "1.4.1", "tslib": "2.3.1", - "uplot": "1.6.17", + "uplot": "1.6.18", "uuid": "8.3.0" }, "devDependencies": { @@ -97,20 +97,20 @@ "@rollup/plugin-commonjs": "21.0.1", "@rollup/plugin-image": "2.1.1", "@rollup/plugin-node-resolve": "13.0.6", - "@storybook/addon-a11y": "6.3.7", - "@storybook/addon-actions": "6.3.7", - "@storybook/addon-docs": "6.3.7", - "@storybook/addon-essentials": "6.3.7", - "@storybook/addon-knobs": "6.3.0", - "@storybook/addon-storysource": "6.3.7", - "@storybook/addons": "6.3.7", - "@storybook/api": "6.3.7", - "@storybook/builder-webpack5": "6.3.7", - "@storybook/components": "6.3.7", - "@storybook/core-events": "6.3.7", - "@storybook/manager-webpack5": "6.3.7", - "@storybook/react": "6.3.7", - "@storybook/theming": "6.3.7", + "@storybook/addon-a11y": "^6.4.13", + "@storybook/addon-actions": "^6.4.13", + "@storybook/addon-docs": "^6.4.13", + "@storybook/addon-essentials": "^6.4.13", + "@storybook/addon-knobs": "^6.4.0", + "@storybook/addon-storysource": "^6.4.13", + "@storybook/addons": "^6.4.13", + "@storybook/api": "^6.4.13", + "@storybook/builder-webpack5": "^6.4.13", + "@storybook/components": "^6.4.13", + "@storybook/core-events": "^6.4.13", + "@storybook/manager-webpack5": "^6.4.13", + "@storybook/react": "^6.4.13", + "@storybook/theming": "^6.4.13", "@swc/helpers": "0.2.13", "@testing-library/dom": "8.10.1", "@testing-library/jest-dom": "5.11.9", diff --git a/packages/grafana-ui/src/components/Alert/Alert.story.tsx b/packages/grafana-ui/src/components/Alert/Alert.story.tsx index a90a497397922..cb159b1862e8b 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.story.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.story.tsx @@ -62,6 +62,122 @@ export const Examples: Story = ({ severity, title, buttonContent }) => { ))} + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam metus urna, aliquam eu scelerisque non, + facilisis eget est. Morbi eleifend egestas massa id vulputate. Fusce dignissim magna lacus, ut molestie odio + feugiat sed. Cras fringilla justo sit amet turpis scelerisque, a volutpat purus iaculis. Nunc sagittis + molestie faucibus. Curabitur at neque luctus, pellentesque urna eget, posuere urna. Nunc malesuada elit in + ipsum dictum egestas. Praesent convallis mauris massa, porta mattis ex gravida ut. Proin consectetur ultrices + tortor sit amet efficitur. Suspendisse nec turpis dapibus mauris venenatis maximus quis eget orci. Ut semper + enim magna, ullamcorper elementum sapien pharetra vitae. Vivamus at nulla ut metus bibendum ornare et ut leo. + Proin ante turpis, ornare a malesuada et, rutrum nec lorem. Maecenas vestibulum orci vel nibh convallis + eleifend. Quisque vitae consectetur massa, vitae elementum mauris. Pellentesque sit amet ligula lorem. Fusce + sit amet lorem non augue rutrum varius. Donec sed imperdiet libero, eget venenatis elit. Fusce porttitor + dapibus urna. Duis fringilla ante vel tempor tincidunt. In euismod vestibulum odio sit amet iaculis. Donec vel + dapibus libero. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi lacinia commodo lectus. Aenean + in magna eget lectus luctus suscipit et vitae erat. Pellentesque quis ligula id lorem egestas sollicitudin sit + amet sed sem. Nullam et nibh a odio rhoncus efficitur sed nec est. Sed commodo lacus vitae sem congue, + accumsan dignissim metus iaculis. Praesent in dignissim nisl. Aliquam facilisis, sapien eget porttitor + ultrices, massa libero bibendum odio, at ornare diam arcu ac massa. Vestibulum egestas leo eget lorem congue + condimentum. Praesent egestas, neque id gravida vehicula, augue ex scelerisque lectus, finibus pellentesque + enim dolor vel ante. Cras convallis, sem at malesuada tincidunt, diam urna auctor leo, sed laoreet est ex in + libero. Ut condimentum ante eget ex gravida, id tempus metus ultricies. Pellentesque placerat, massa id + laoreet molestie, justo nisl varius metus, maximus vehicula erat libero vitae nulla. Mauris rhoncus ligula + vitae volutpat auctor. Suspendisse potenti. Quisque quis orci faucibus, ullamcorper dolor eget, mollis massa. + Etiam eu molestie ipsum. Sed laoreet diam metus, luctus maximus erat viverra quis. Ut eu felis dictum, + tincidunt erat sit amet, scelerisque neque. Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. Phasellus sit amet est tristique, fermentum massa ut, viverra metus. Interdum et + malesuada fames ac ante ipsum primis in faucibus. Nunc iaculis nunc elit, ut feugiat ipsum egestas eget. + Vestibulum pulvinar ligula mi, quis lacinia diam suscipit eget. Etiam consectetur vel nunc at hendrerit. + Pellentesque blandit eleifend aliquam. Etiam et malesuada purus, et bibendum sapien. Phasellus tincidunt + consequat eros consequat sodales. Vestibulum quis viverra neque. Integer sit amet lacinia nunc. Ut cursus, + elit id faucibus elementum, elit nunc dapibus tellus, non ornare nisi sapien et eros. Nunc sit amet suscipit + arcu. Nulla ut nunc tempor, auctor massa sed, consectetur orci. Pellentesque erat ante, placerat eget dictum + elementum, dapibus et ipsum. Nunc sit amet nulla gravida, finibus felis vel, tempus sem. In urna purus, + accumsan quis aliquam et, condimentum ac urna. Nullam volutpat ullamcorper sapien, quis ultricies purus + dignissim aliquam. Mauris quis enim ante. Etiam vulputate faucibus placerat. Ut pellentesque, purus vitae + euismod cursus, lacus enim vulputate sapien, in porttitor erat dui eu lectus. Duis eleifend, massa vel + vehicula gravida, magna urna rutrum ligula, vitae mollis ipsum neque id enim. Donec varius tristique nisi, et + vestibulum dolor efficitur eget. Cras mauris leo, bibendum eget pretium a, tincidunt faucibus massa. + Vestibulum hendrerit arcu magna, vel consequat est euismod nec. Vestibulum non lacus porttitor, congue tortor + ut, venenatis elit. Duis at lectus arcu. Nunc quis sapien eu ipsum rutrum accumsan. Orci varius natoque + penatibus et magnis dis parturient montes, nascetur ridiculus mus. Vivamus quis sapien luctus, volutpat nulla + eget, gravida nunc. Aenean placerat a felis quis imperdiet. Sed sapien tellus, ultrices non ipsum eget, + pretium rhoncus quam. Aliquam erat volutpat. Maecenas at interdum turpis, eu mattis ligula. Class aptent + taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In lobortis felis a leo + ultricies, venenatis mollis felis lobortis. Suspendisse placerat vel ante vel euismod. Aenean sit amet + ullamcorper mauris, id consectetur est. Ut ultricies enim non quam condimentum, et congue arcu commodo. + Praesent convallis eleifend turpis, vitae feugiat turpis imperdiet sit amet. Class aptent taciti sociosqu ad + litora torquent per conubia nostra, per inceptos himenaeos. Quisque vulputate porttitor mattis. Pellentesque + sed ullamcorper lectus. Suspendisse velit tortor, viverra eget facilisis condimentum, accumsan sit amet felis. + Cras lobortis mi fermentum ligula consectetur, vitae tincidunt mauris scelerisque. Aenean ac condimentum erat, + quis lacinia lacus. Ut magna nibh, tempor et ligula suscipit, placerat laoreet ipsum. In semper semper nisl. + Donec risus lorem, tempor sed sollicitudin vitae, fringilla et mi. Vivamus pulvinar quam nisl, et tincidunt + justo tempus quis. Duis semper magna nunc, vitae faucibus lectus facilisis sed. Phasellus consequat arcu vel + interdum fermentum. In condimentum euismod neque, sed aliquet mauris posuere nec. Etiam metus eros, + pellentesque eget scelerisque id, porttitor at ligula. Curabitur eget nibh maximus enim lobortis sodales. + Etiam vulputate ligula lobortis vestibulum pulvinar. Curabitur eros justo, accumsan sed elit ac, mattis + lacinia nisi. Suspendisse ullamcorper lectus sit amet tellus condimentum porttitor. Duis cursus, neque et + aliquam congue, odio lectus porta elit, id lacinia dolor justo non leo. Aliquam vehicula at tellus ullamcorper + tincidunt. Phasellus neque nibh, convallis sit amet arcu sit amet, convallis egestas tortor. Etiam sit amet + vehicula quam. Praesent id consequat lacus, ac facilisis quam. Integer tristique lorem eros, id consequat + lorem lobortis vitae. Aliquam luctus purus eget sem molestie iaculis. Duis nisl risus, sodales sit amet nunc + vitae, volutpat cursus augue. Pellentesque congue massa eu metus pellentesque consectetur at vel neque. Donec + bibendum hendrerit erat, vitae dictum enim lobortis a. Quisque ac dapibus tellus, sit amet facilisis orci. + Cras pretium tortor non condimentum semper. Phasellus mollis condimentum blandit. Pellentesque at arcu risus. + Vivamus sit amet dui semper, suscipit est nec, elementum arcu. Praesent ante turpis, convallis ac leo eget, + rutrum bibendum ante. Sed venenatis vehicula lobortis. Phasellus consectetur velit nec congue venenatis. + Interdum et malesuada fames ac ante ipsum primis in faucibus. Nullam sagittis, arcu quis sagittis vestibulum, + odio lorem tempus massa, non condimentum enim turpis ut orci. Vivamus sit amet dignissim eros. Vestibulum ante + ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Phasellus efficitur est eu semper + luctus. Nunc gravida tristique urna, et mollis leo porttitor suscipit. In hac habitasse platea dictumst. Etiam + tempus varius mattis. Suspendisse potenti. Duis finibus ornare tortor, a auctor lacus dictum id. Suspendisse + eget auctor lorem, sollicitudin placerat ipsum. Aliquam suscipit ante at gravida tincidunt. Donec vel turpis + dui. Ut facilisis, dui id fermentum ultrices, nunc mi pretium ex, eu lobortis sem tellus porttitor nulla. Nam + sit amet mauris justo. Maecenas libero est, dignissim vel dolor ac, rutrum accumsan tellus. Nulla in lacinia + orci, facilisis feugiat mi. Nunc sit amet ultricies dolor. Sed ac interdum lacus. Quisque facilisis viverra + orci vel tincidunt. Suspendisse feugiat ligula non justo molestie viverra. Cras sed ultricies quam. Duis + aliquam ante lacus, non posuere neque pretium id. Fusce at orci facilisis, pretium justo eu, vestibulum sem. + Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Duis at lorem ultrices, + tincidunt nulla ac, molestie ligula. Phasellus erat nunc, consectetur ut velit vel, porta fringilla ante. + Vestibulum laoreet lorem erat, ultricies porta ex venenatis vel. Donec vestibulum enim id suscipit + pellentesque. Donec vitae dictum arcu. In ac tellus dignissim, imperdiet tellus quis, malesuada nisl. Aliquam + finibus urna commodo purus ultricies, non vulputate risus ullamcorper. Cras semper ultrices posuere. Etiam + massa nisi, vehicula sit amet volutpat sit amet, commodo vel mi. Maecenas commodo consectetur ipsum nec + ultrices. Sed hendrerit quam nulla, ut tincidunt tortor commodo eget. Vestibulum euismod condimentum + porttitor. Mauris egestas posuere justo ac suscipit. Nulla auctor tellus nec eros rhoncus, a congue odio + accumsan. Phasellus tempus risus ut orci commodo, sed lobortis ex porta. Nullam ligula erat, pretium ut nibh + volutpat, tincidunt pellentesque purus. Vivamus cursus eget nisi id ornare. Phasellus ut aliquet turpis. + Vestibulum ornare ex eros, quis accumsan neque porta id. Pellentesque eleifend, tortor eget dignissim + convallis, nisl felis fringilla dolor, dapibus bibendum mauris lacus dignissim metus. Cras cursus faucibus + condimentum. Curabitur non libero risus. Morbi nisi augue, aliquam quis neque sit amet, commodo ullamcorper + justo. Aenean pulvinar accumsan nibh, vel bibendum turpis rutrum vitae. Donec condimentum justo vel luctus + consectetur. Donec id nisl eu erat sollicitudin venenatis at eget justo. Mauris ac nulla laoreet, semper + tortor sed, egestas odio. Fusce ex ligula, aliquet sed tortor sit amet, porta malesuada libero. Aenean varius + tellus in enim fringilla tristique. Duis risus diam, condimentum ac ornare sed, imperdiet non lectus. Mauris + auctor condimentum pulvinar. Pellentesque dapibus ultricies neque eu egestas. Phasellus molestie volutpat + tincidunt. Aenean tristique felis aliquam consectetur fringilla. Pellentesque a mauris nibh. Maecenas commodo + dignissim sem, eget laoreet purus laoreet eu. Sed arcu mi, facilisis quis finibus ac, pulvinar ut sem. Nulla + facilisi. Maecenas in elit diam. Vivamus aliquam enim eget enim condimentum fermentum. Sed convallis tellus + nisl, eget congue diam hendrerit et. Nulla posuere, sem non condimentum aliquet, leo arcu scelerisque leo, sit + amet maximus ante massa in odio. Nunc dictum nibh ac dui luctus, sit amet tempor massa imperdiet. Nunc dictum + ligula augue, a auctor odio rutrum a. Cras auctor, mi vitae maximus dictum, purus mauris ornare libero, ac + consequat mauris diam sit amet mauris. In egestas eu felis ut volutpat. Etiam dignissim justo vitae sagittis + venenatis. Vestibulum fermentum lectus nisl, vitae tempor ipsum lobortis nec. Nam porttitor est vel libero + mollis, vel accumsan nisi iaculis. Suspendisse interdum consequat sapien, quis aliquam leo condimentum ut. In + ut risus sit amet quam viverra facilisis. Mauris dapibus tortor id purus egestas mollis. Quisque vitae ornare + ex. Vivamus non accumsan lectus. Mauris at iaculis ex. Curabitur rutrum magna nibh, eu accumsan felis + tristique in. Donec in fringilla orci. Quisque ante dolor, tempor vitae lacinia non, malesuada at dui. Ut + tincidunt bibendum magna vitae ornare. Suspendisse a ipsum ut nulla ornare pretium. Donec malesuada rhoncus + facilisis. Integer nulla mi, sagittis vel purus non, viverra sodales lectus. Pellentesque habitant morbi + tristique senectus et netus et malesuada fames ac turpis egestas. Aenean tristique turpis neque, nec elementum + ipsum condimentum tempor. Pellentesque mauris magna, venenatis ac sodales et, fermentum sed nunc. Pellentesque + dignissim lacinia suscipit. Pellentesque mollis tellus justo, eu facilisis neque tempor a. Aenean id metus + gravida, consectetur ligula eget, tincidunt sapien. Morbi in tellus id ex vestibulum sagittis sed vitae risus. + In vehicula sed arcu auctor ullamcorper. Etiam elementum mollis fringilla. + + ); }; diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index 831d0519e865b..b25ecc025f80e 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -53,7 +53,7 @@ export const Alert = React.forwardRef(
-
+
{title}
{children &&
{children}
}
@@ -130,6 +130,8 @@ const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: bool content: css` color: ${theme.colors.text.secondary}; padding-top: ${theme.spacing(1)}; + max-height: 50vh; + overflow-y: scroll; `, buttonWrapper: css` padding: ${theme.spacing(1)}; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index 307473a6924c0..b5d4385168699 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -170,6 +170,18 @@ describe('BarGauge', () => { }); }); + describe('Vertical bar', () => { + it('should adjust empty region to always have same width as colored bar', () => { + const props = getProps({ + width: 150, + value: getValue(100), + orientation: VizOrientation.Vertical, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.emptyBar.width).toBe('150px'); + }); + }); + describe('Vertical bar without title', () => { it('should not include title height in height', () => { const props = getProps({ @@ -218,9 +230,7 @@ describe('BarGauge', () => { const styles = getTitleStyles(props); expect(styles.wrapper.flexDirection).toBe('column'); }); - }); - describe('Horizontal bar with title', () => { it('should place below if height < 40', () => { const props = getProps({ height: 30, @@ -249,6 +259,19 @@ describe('BarGauge', () => { expect(styles2.title.width).toBe('43px'); }); + it('Should limit text length to 40%', () => { + const props = getProps({ + height: 30, + value: getValue( + 100, + 'saaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ), + orientation: VizOrientation.Horizontal, + }); + const styles = getTitleStyles(props); + expect(styles.title.width).toBe('119px'); + }); + it('should use alignmentFactors if provided', () => { const props = getProps({ height: 30, @@ -262,6 +285,16 @@ describe('BarGauge', () => { const styles = getTitleStyles(props); expect(styles.title.width).toBe('37px'); }); + + it('should adjust empty region to always have same height as colored bar', () => { + const props = getProps({ + height: 150, + value: getValue(100), + orientation: VizOrientation.Horizontal, + }); + const styles = getBasicAndGradientStyles(props); + expect(styles.emptyBar.height).toBe('150px'); + }); }); describe('Gradient', () => { diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 5f6641c819e34..7e5e4bf9ac205 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -268,10 +268,13 @@ function calculateTitleDimensions(props: Props): TitleDimensions { const titleFontSize = titleHeight / TITLE_LINE_HEIGHT; const textSize = measureText(title, titleFontSize); + // Do not allow title to take up more than 40% width + const textWidth = Math.min(textSize.width + 15, width * 0.4); + return { fontSize: text?.titleSize ?? titleFontSize, height: 0, - width: textSize.width + 15, + width: textWidth, placement: 'left', }; } @@ -483,6 +486,9 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles // adjust so that filled in bar is at the bottom emptyBar.bottom = '-3px'; + //adjust empty region to always have same width as colored bar + emptyBar.width = `${valueWidth}px`; + if (isBasic) { // Basic styles barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`; @@ -506,6 +512,9 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles // shift empty region back to fill gaps due to border radius emptyBar.left = '-3px'; + //adjust empty region to always have same height as colored bar + emptyBar.height = `${valueHeight}px`; + if (isBasic) { // Basic styles barStyles.background = `${tinycolor(valueColor).setAlpha(0.35).toRgbString()}`; diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index a5910cdd095ea..3907e77f271e7 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -56,6 +56,7 @@ exports[`BarGauge Render with basic options should render 1`] = ` "borderRadius": "3px", "display": "flex", "flexGrow": 1, + "height": "300px", "left": "-3px", "position": "relative", } diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index 52e8d105c0eb1..c424dc99f5f2e 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -1,51 +1,49 @@ -import React, { PureComponent } from 'react'; -import Clipboard from 'clipboard'; +import React, { useCallback, useRef } from 'react'; import { Button, ButtonProps } from '../Button'; +/** @deprecated Will be removed in next major release */ +interface ClipboardEvent { + action: string; + text: string; + trigger: Element; + clearSelection(): void; +} + 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; + onClipboardCopy?(e: ClipboardEvent): void; /** Callback when there was an error copying the text */ - onClipboardError?(e: Clipboard.Event): void; + onClipboardError?(e: ClipboardEvent): 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 ( - - ); - } +const dummyClearFunc = () => {}; + +export function ClipboardButton({ onClipboardCopy, onClipboardError, children, getText, ...buttonProps }: Props) { + // Can be removed in 9.x + const buttonRef = useRef(null); + const copyText = useCallback(() => { + const copiedText = getText(); + const dummyEvent: ClipboardEvent = { + action: 'copy', + clearSelection: dummyClearFunc, + text: copiedText, + trigger: buttonRef.current!, + }; + + if (navigator.clipboard) { + navigator.clipboard + .writeText(copiedText) + .then(() => (onClipboardCopy?.(dummyEvent), () => onClipboardError?.(dummyEvent))); + } else { + console.error("Clipboard API not accessible. Please make sure you're running over HTTPS or on localhost"); + } + }, [getText, onClipboardCopy, onClipboardError]); + + return ( + + ); } diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index 1ef09c8ff7bd6..3e2ac0adc993b 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -5,20 +5,26 @@ import { Icon } from '..'; import { GrafanaTheme2 } from '@grafana/data'; export interface Props { - label: string; + label: ReactNode; isOpen: boolean; + /** Callback for the toggle functionality */ + onToggle?: (isOpen: boolean) => void; children: ReactNode; } -export const CollapsableSection: FC = ({ label, isOpen, children }) => { +export const CollapsableSection: FC = ({ label, isOpen, onToggle, children }) => { const [open, toggleOpen] = useState(isOpen); const styles = useStyles2(collapsableSectionStyles); const headerStyle = open ? styles.header : styles.headerCollapsed; const tooltip = `Click to ${open ? 'collapse' : 'expand'}`; + const onClick = () => { + onToggle?.(!open); + toggleOpen(!open); + }; return (
-
toggleOpen(!open)} className={headerStyle} title={tooltip}> +
{label}
diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index bd43e86ae74c1..fa228b87877c2 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -1,10 +1,10 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import { isNil } from 'lodash'; -import classNames from 'classnames'; import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import classNames from 'classnames'; +import { isNil } from 'lodash'; +import React, { FC, RefCallback, useCallback, useEffect, useRef } from 'react'; import Scrollbars, { positionValues } from 'react-custom-scrollbars-2'; import { useStyles2 } from '../../themes'; -import { GrafanaTheme2 } from '@grafana/data'; export type ScrollbarPosition = positionValues; @@ -16,6 +16,7 @@ interface Props { hideTracksWhenNotNeeded?: boolean; hideHorizontalTrack?: boolean; hideVerticalTrack?: boolean; + scrollRefCallback?: RefCallback; scrollTop?: number; setScrollTop?: (position: ScrollbarPosition) => void; autoHeightMin?: number | string; @@ -35,11 +36,17 @@ export const CustomScrollbar: FC = ({ hideTracksWhenNotNeeded = false, hideHorizontalTrack, hideVerticalTrack, + scrollRefCallback, updateAfterMountMs, scrollTop, children, }) => { - const ref = useRef(null); + const ref = useRef(null); + useEffect(() => { + if (ref.current) { + scrollRefCallback?.(ref.current.view); + } + }, [ref, scrollRefCallback]); const styles = useStyles2(getStyles); const updateScroll = () => { diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx index 6e5a79b0d275e..586221ab2c464 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx @@ -68,6 +68,7 @@ const CustomHeaderRow: React.FC = ({ header, onBlur, onCha /> = (props) => { const accessSelect = ( = (props) => { timeZone={timeZone} isReversed={isReversed} /> -
+ ); }; diff --git a/packages/grafana-ui/src/components/Forms/Field.tsx b/packages/grafana-ui/src/components/Forms/Field.tsx index 2f1d1230edec0..8b6a40c2e8a9a 100644 --- a/packages/grafana-ui/src/components/Forms/Field.tsx +++ b/packages/grafana-ui/src/components/Forms/Field.tsx @@ -4,7 +4,7 @@ import { stylesFactory, useTheme2 } from '../../themes'; import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { FieldValidationMessage } from './FieldValidationMessage'; -import { getChildId } from '../../utils/children'; +import { getChildId } from '../../utils/reactUtils'; export interface FieldProps extends HTMLAttributes { /** Form input element, i.e Input or Switch */ diff --git a/packages/grafana-ui/src/components/Forms/InlineField.tsx b/packages/grafana-ui/src/components/Forms/InlineField.tsx index 583af8b9b80c9..1f3291f658c3c 100644 --- a/packages/grafana-ui/src/components/Forms/InlineField.tsx +++ b/packages/grafana-ui/src/components/Forms/InlineField.tsx @@ -5,7 +5,7 @@ import { useTheme } from '../../themes'; import { InlineLabel } from './InlineLabel'; import { PopoverContent } from '../Tooltip/Tooltip'; import { FieldProps } from './Field'; -import { getChildId } from '../../utils/children'; +import { getChildId } from '../../utils/reactUtils'; export interface Props extends Omit { /** Content for the label's tooltip */ diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx index 35bc7f3be085c..aca931e2bda24 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx @@ -101,6 +101,7 @@ export class Select extends PureComponent> { onOpenMenu, allowCustomValue, formatCreateLabel, + 'aria-label': ariaLabel, } = this.props; let widthClass = ''; @@ -151,6 +152,7 @@ export class Select extends PureComponent> { onMenuOpen={onOpenMenuInternal} onMenuClose={onCloseMenuInternal} tabSelectsValue={tabSelectsValue} + aria-label={ariaLabel} {...creatableOptions} /> ); diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index a168ada145789..e33de639ef235 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -6,6 +6,7 @@ import { DataFrame, DataHoverClearEvent, DataHoverEvent, + Field, FieldMatcherID, fieldMatchers, LegacyGraphHoverEvent, @@ -44,8 +45,8 @@ export interface GraphNGProps extends Themeable2 { legend: VizLegendOptions; fields?: XYFieldMatchers; // default will assume timeseries data renderers?: Renderers; - tweakScale?: (opts: ScaleProps) => ScaleProps; - tweakAxis?: (opts: AxisProps) => AxisProps; + tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; + tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; onLegendClick?: (event: GraphNGLegendEvent) => void; children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder; diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index dd07370a82c9a..fe73354308645 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -1,6 +1,8 @@ import { XYFieldMatchers } from './types'; -import { ArrayVector, DataFrame, FieldType, outerJoinDataFrames } from '@grafana/data'; +import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames } from '@grafana/data'; import { nullToUndefThreshold } from './nullToUndefThreshold'; +import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; +import { FIXED_UNIT } from './GraphNG'; // will mutate the DataFrame's fields' values function applySpanNullsThresholds(frame: DataFrame) { @@ -38,3 +40,36 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers return alignedFrame && applySpanNullsThresholds(alignedFrame); } + +export function buildScaleKey(config: FieldConfig) { + const defaultPart = 'na'; + + const scaleRange = `${config.min !== undefined ? config.min : defaultPart}-${ + config.max !== undefined ? config.max : defaultPart + }`; + + const scaleSoftRange = `${config.custom?.axisSoftMin !== undefined ? config.custom.axisSoftMin : defaultPart}-${ + config.custom?.axisSoftMax !== undefined ? config.custom.axisSoftMax : defaultPart + }`; + + const scalePlacement = `${ + config.custom?.axisPlacement !== undefined ? config.custom?.axisPlacement : AxisPlacement.Auto + }`; + + const scaleUnit = config.unit ?? FIXED_UNIT; + + const scaleDistribution = config.custom?.scaleDistribution + ? getScaleDistributionPart(config.custom.scaleDistribution) + : ScaleDistribution.Linear; + + const scaleLabel = Boolean(config.custom?.axisLabel) ? config.custom!.axisLabel : defaultPart; + + return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}`; +} + +function getScaleDistributionPart(config: ScaleDistributionConfig) { + if (config.type === ScaleDistribution.Log) { + return `${config.type}${config.log}`; + } + return config.type; +} diff --git a/packages/grafana-ui/src/components/Icon/Icon.tsx b/packages/grafana-ui/src/components/Icon/Icon.tsx index ab7734d4f806a..78701ff8a3d2a 100644 --- a/packages/grafana-ui/src/components/Icon/Icon.tsx +++ b/packages/grafana-ui/src/components/Icon/Icon.tsx @@ -7,7 +7,36 @@ import { IconName, IconType, IconSize } from '../../types/icon'; import SVG from 'react-inlinesvg'; import { cacheInitialized, initIconCache, iconRoot } from './iconBundle'; -const alwaysMonoIcons: IconName[] = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add', 'library-panel']; +const alwaysMonoIcons: IconName[] = [ + 'grafana', + 'favorite', + 'heart-break', + 'heart', + 'panel-add', + 'library-panel', + 'percona-database', + 'percona-analytics', + 'percona-cluster', + 'percona-cluster-network', + 'percona-cluster-computing', + 'percona-dashboard', + 'percona-kubernetes', + 'percona-summary', + 'percona-inventory', + 'percona-add', + 'percona-alert', + 'percona-disk', + 'percona-memory', + 'percona-temperature', + 'percona-cpu', + 'percona-surface', + 'percona-network', + 'percona-process', + 'percona-setting', + 'percona-database-checks', + 'pmm-logo', + 'qan-logo', +]; export interface IconProps extends React.HTMLAttributes { name: IconName; diff --git a/packages/grafana-ui/src/components/Icon/iconBundle.ts b/packages/grafana-ui/src/components/Icon/iconBundle.ts index e703b9e911ef0..c3b629eaffc9f 100644 --- a/packages/grafana-ui/src/components/Icon/iconBundle.ts +++ b/packages/grafana-ui/src/components/Icon/iconBundle.ts @@ -164,6 +164,29 @@ import u1151 from '!!raw-loader!../../../../../public/img/icons/mono/heart.svg'; import u1152 from '!!raw-loader!../../../../../public/img/icons/mono/heart-break.svg'; import u1153 from '!!raw-loader!../../../../../public/img/icons/mono/panel-add.svg'; import u1154 from '!!raw-loader!../../../../../public/img/icons/mono/library-panel.svg'; +import u1155 from '!!raw-loader!../../../../../public/img/icons/unicons/record-audio.svg'; +import u1156 from '!!raw-loader!../../../../../public/img/icons/mono/percona-add.svg'; +import u1157 from '!!raw-loader!../../../../../public/img/icons/mono/percona-alert.svg'; +import u1158 from '!!raw-loader!../../../../../public/img/icons/mono/percona-analytics.svg'; +import u1159 from '!!raw-loader!../../../../../public/img/icons/mono/percona-cluster-computing.svg'; +import u1160 from '!!raw-loader!../../../../../public/img/icons/mono/percona-cluster-network.svg'; +import u1161 from '!!raw-loader!../../../../../public/img/icons/mono/percona-cluster.svg'; +import u1162 from '!!raw-loader!../../../../../public/img/icons/mono/percona-cpu.svg'; +import u1163 from '!!raw-loader!../../../../../public/img/icons/mono/percona-dashboard.svg'; +import u1164 from '!!raw-loader!../../../../../public/img/icons/mono/percona-database-checks.svg'; +import u1165 from '!!raw-loader!../../../../../public/img/icons/mono/percona-database.svg'; +import u1166 from '!!raw-loader!../../../../../public/img/icons/mono/percona-disk.svg'; +import u1167 from '!!raw-loader!../../../../../public/img/icons/mono/percona-inventory.svg'; +import u1168 from '!!raw-loader!../../../../../public/img/icons/mono/percona-kubernetes.svg'; +import u1169 from '!!raw-loader!../../../../../public/img/icons/mono/percona-memory.svg'; +import u1170 from '!!raw-loader!../../../../../public/img/icons/mono/percona-network.svg'; +import u1171 from '!!raw-loader!../../../../../public/img/icons/mono/percona-process.svg'; +import u1172 from '!!raw-loader!../../../../../public/img/icons/mono/percona-setting.svg'; +import u1173 from '!!raw-loader!../../../../../public/img/icons/mono/percona-summary.svg'; +import u1174 from '!!raw-loader!../../../../../public/img/icons/mono/percona-surface.svg'; +import u1175 from '!!raw-loader!../../../../../public/img/icons/mono/percona-temperature.svg'; +import u1176 from '!!raw-loader!../../../../../public/img/icons/mono/pmm-logo.svg'; +import u1177 from '!!raw-loader!../../../../../public/img/icons/mono/qan-logo.svg'; export function initIconCache() { cacheInitialized = true; @@ -328,4 +351,27 @@ export function initIconCache() { cacheItem(u1152, 'mono/heart-break.svg'); cacheItem(u1153, 'mono/panel-add.svg'); cacheItem(u1154, 'mono/library-panel.svg'); + cacheItem(u1155, 'unicons/record-audio.svg'); + cacheItem(u1156, 'mono/percona-add.svg'); + cacheItem(u1157, 'mono/percona-alert.svg'); + cacheItem(u1158, 'mono/percona-analytics.svg'); + cacheItem(u1159, 'mono/percona-cluster-computing.svg'); + cacheItem(u1160, 'mono/percona-cluster-network.svg'); + cacheItem(u1161, 'mono/percona-cluster.svg'); + cacheItem(u1162, 'mono/percona-cpu.svg'); + cacheItem(u1163, 'mono/percona-dashboard.svg'); + cacheItem(u1164, 'mono/percona-database-checks.svg'); + cacheItem(u1165, 'mono/percona-database.svg'); + cacheItem(u1166, 'mono/percona-disk.svg'); + cacheItem(u1167, 'mono/percona-inventory.svg'); + cacheItem(u1168, 'mono/percona-kubernetes.svg'); + cacheItem(u1169, 'mono/percona-memory.svg'); + cacheItem(u1170, 'mono/percona-network.svg'); + cacheItem(u1171, 'mono/percona-process.svg'); + cacheItem(u1172, 'mono/percona-setting.svg'); + cacheItem(u1173, 'mono/percona-summary.svg'); + cacheItem(u1174, 'mono/percona-surface.svg'); + cacheItem(u1175, 'mono/percona-temperature.svg'); + cacheItem(u1176, 'mono/pmm-logo.svg'); + cacheItem(u1177, 'mono/qan-logo.svg'); } diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 2830cb114cb3b..4f2eb4664b256 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -58,8 +58,8 @@ export const Input = React.forwardRef((props, ref) => { className={styles.input} {...restProps} style={{ - paddingLeft: prefixRect ? prefixRect.width + 12 : undefined, - paddingRight: suffixRect && (suffix || loading) ? suffixRect.width + 12 : undefined, + paddingLeft: prefix ? prefixRect.width + 12 : undefined, + paddingRight: suffix || loading ? suffixRect.width + 12 : undefined, }} /> diff --git a/packages/grafana-ui/src/components/MatchersUI/utils.ts b/packages/grafana-ui/src/components/MatchersUI/utils.ts index 913994a10f0ba..ccbf954508138 100644 --- a/packages/grafana-ui/src/components/MatchersUI/utils.ts +++ b/packages/grafana-ui/src/components/MatchersUI/utils.ts @@ -68,7 +68,8 @@ export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field) export function useSelectOptions( displayNames: FrameFieldsDisplayNames, currentName?: string, - firstItem?: SelectableValue + firstItem?: SelectableValue, + fieldType?: string ): Array> { return useMemo(() => { let found = false; @@ -81,11 +82,13 @@ export function useSelectOptions( found = true; } const field = displayNames.fields.get(name); - options.push({ - value: name, - label: name, - icon: field ? getFieldTypeIcon(field) : undefined, - }); + if (!fieldType || fieldType === field?.type) { + options.push({ + value: name, + label: name, + icon: field ? getFieldTypeIcon(field) : undefined, + }); + } } for (const name of displayNames.raw) { if (!displayNames.display.has(name)) { @@ -106,5 +109,5 @@ export function useSelectOptions( }); } return options; - }, [displayNames, currentName, firstItem]); + }, [displayNames, currentName, firstItem, fieldType]); } diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index c92377aca9178..7b6d694acd591 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,12 +1,14 @@ -import React, { PropsWithChildren, useCallback, useEffect } from 'react'; -import { Portal } from '../Portal/Portal'; import { cx } from '@emotion/css'; +import { FocusScope } from '@react-aria/focus'; +import React, { PropsWithChildren, useCallback, useEffect } from 'react'; + import { useTheme2 } from '../../themes'; import { IconName } from '../../types'; -import { getModalStyles } from './getModalStyles'; -import { ModalHeader } from './ModalHeader'; import { IconButton } from '../IconButton/IconButton'; import { HorizontalGroup } from '../Layout/Layout'; +import { Portal } from '../Portal/Portal'; +import { getModalStyles } from './getModalStyles'; +import { ModalHeader } from './ModalHeader'; export interface Props { /** @deprecated no longer used */ @@ -19,6 +21,7 @@ export interface Props { contentClassName?: string; closeOnEscape?: boolean; closeOnBackdropClick?: boolean; + trapFocus?: boolean; isOpen?: boolean; onDismiss?: () => void; @@ -38,6 +41,7 @@ export function Modal(props: PropsWithChildren) { contentClassName, onDismiss: propsOnDismiss, onClickBackdrop, + trapFocus = true, } = props; const theme = useTheme2(); const styles = getModalStyles(theme); @@ -75,16 +79,18 @@ export function Modal(props: PropsWithChildren) { className={styles.modalBackdrop} onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)} /> -
-
- {typeof title === 'string' && } - {typeof title !== 'string' && title} -
- + +
+
+ {typeof title === 'string' && } + {typeof title !== 'string' && title} +
+ +
+
{children}
-
{children}
-
+ ); } diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index 8989d8d36e638..f69084e05b976 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -131,6 +131,7 @@ class UnthemedCodeEditor extends React.PureComponent { top: 0.5 * theme.spacing.gridSize, bottom: 0.5 * theme.spacing.gridSize, }, + fixedOverflowWidgets: true, // Ensures suggestions menu is drawn on top }; if (!showLineNumbers) { diff --git a/packages/grafana-ui/src/components/Portal/Portal.tsx b/packages/grafana-ui/src/components/Portal/Portal.tsx index b2705ed006c99..5c18f3257f13a 100644 --- a/packages/grafana-ui/src/components/Portal/Portal.tsx +++ b/packages/grafana-ui/src/components/Portal/Portal.tsx @@ -32,7 +32,12 @@ export function Portal(props: PropsWithChildren) { }; }, [portalRoot]); - return ReactDOM.createPortal(
{children}
, node.current); + return ReactDOM.createPortal( +
+ {children} +
, + node.current + ); } export const RefForwardingPortal = React.forwardRef((props, ref) => { diff --git a/packages/grafana-ui/src/components/Segment/SegmentSection.tsx b/packages/grafana-ui/src/components/Segment/SegmentSection.tsx index 8cc5b7f751d1e..a724c310391a6 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentSection.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentSection.tsx @@ -12,11 +12,14 @@ import { InlineFieldRow } from '../Forms/InlineFieldRow'; */ export const SegmentSection = ({ label, + htmlFor, children, fill, }: { // Name of the section label: string; + // htmlFor for the label + htmlFor?: string; // List of components in the section children: React.ReactNode; // Fill the space at the end @@ -26,7 +29,7 @@ export const SegmentSection = ({ return ( <> - + {label} {children} diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index 81104c38bcb90..b6219969017e0 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SelectableValue } from '@grafana/data'; import { SelectCommonProps, MultiSelectCommonProps, SelectAsyncProps } from './types'; import { SelectBase } from './SelectBase'; +import { SelectContainer, SelectContainerProps } from './SelectContainer'; export function Select(props: SelectCommonProps) { return ; @@ -31,3 +32,5 @@ export function AsyncMultiSelect(props: AsyncMultiSelectProps) { // @ts-ignore return ; } + +export { SelectContainer, SelectContainerProps }; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index 18c5d853fe6fd..6bae371b0703d 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -1,4 +1,4 @@ -import React, { ComponentProps, useCallback } from 'react'; +import React, { ComponentProps, useCallback, useEffect, useRef, useState } from 'react'; import { default as ReactSelect } from 'react-select'; import Creatable from 'react-select/creatable'; import { default as ReactAsyncSelect } from 'react-select/async'; @@ -145,6 +145,27 @@ export function SelectBase({ } const theme = useTheme2(); const styles = getSelectStyles(theme); + + const reactSelectRef = useRef<{ controlRef: HTMLElement }>(null); + const [closeToBottom, setCloseToBottom] = useState(false); + + // Infer the menu position for asynchronously loaded options. menuPlacement="auto" doesn't work when the menu is + // automatically opened when the component is created (it happens in SegmentSelect by setting menuIsOpen={true}). + // We can remove this workaround when the bug in react-select is fixed: https://github.com/JedWatson/react-select/issues/4936 + // Note: we use useEffect instead of hooking into onMenuOpen due to another bug: https://github.com/JedWatson/react-select/issues/3375 + useEffect(() => { + if ( + loadOptions && + isOpen && + reactSelectRef.current && + reactSelectRef.current.controlRef && + menuPlacement === 'auto' + ) { + const distance = window.innerHeight - reactSelectRef.current.controlRef.getBoundingClientRect().bottom; + setCloseToBottom(distance < maxMenuHeight); + } + }, [maxMenuHeight, menuPlacement, loadOptions, isOpen]); + const onChangeWithEmpty = useCallback( (value: SelectValue, action: ActionMeta) => { if (isMulti && (value === undefined || value === null)) { @@ -205,7 +226,7 @@ export function SelectBase({ minMenuHeight, maxVisibleValues, menuIsOpen: isOpen, - menuPlacement, + menuPlacement: menuPlacement === 'auto' && closeToBottom ? 'top' : menuPlacement, menuPosition, menuShouldBlockScroll: true, menuPortalTarget: menuShouldPortal ? document.body : undefined, @@ -247,6 +268,7 @@ export function SelectBase({ return ( <> > +export interface SelectContainerProps> extends BaseContainerProps { isFocused: boolean; } export const SelectContainer = >( - props: ContainerProps + props: SelectContainerProps ) => { const { isDisabled, diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index 8dbe5df52a4f2..d32e53ccf6ab9 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { FC, RefCallback } from 'react'; import { useTheme2 } from '../../themes/ThemeContext'; import { getSelectStyles } from './getSelectStyles'; import { cx } from '@emotion/css'; @@ -9,23 +9,22 @@ import { IconName } from '../../types'; interface SelectMenuProps { maxHeight: number; - innerRef: React.Ref; + innerRef: RefCallback; innerProps: {}; } -export const SelectMenu = React.forwardRef>((props, ref) => { +export const SelectMenu: FC = ({ children, maxHeight, innerRef, innerProps }) => { const theme = useTheme2(); const styles = getSelectStyles(theme); - const { children, maxHeight, innerRef, innerProps } = props; return ( -
- +
+ {children}
); -}); +}; SelectMenu.displayName = 'SelectMenu'; @@ -34,38 +33,44 @@ interface SelectMenuOptionProps { isFocused: boolean; isSelected: boolean; innerProps: any; + innerRef: RefCallback; renderOptionLabel?: (value: SelectableValue) => JSX.Element; data: SelectableValue; } -export const SelectMenuOptions = React.forwardRef>>( - (props, ref) => { - const theme = useTheme2(); - const styles = getSelectStyles(theme); - const { children, innerProps, data, renderOptionLabel, isSelected, isFocused } = props; +export const SelectMenuOptions: FC> = ({ + children, + data, + innerProps, + innerRef, + isFocused, + isSelected, + renderOptionLabel, +}) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); - return ( -
- {data.icon && } - {data.imgUrl && {data.label} -
- {renderOptionLabel ? renderOptionLabel(data) : children} - {data.description &&
{data.description}
} - {data.component && } -
+ return ( +
+ {data.icon && } + {data.imgUrl && {data.label} +
+ {renderOptionLabel ? renderOptionLabel(data) : children} + {data.description &&
{data.description}
} + {data.component && }
- ); - } -); +
+ ); +}; SelectMenuOptions.displayName = 'SelectMenuOptions'; diff --git a/packages/grafana-ui/src/components/Spinner/Spinner.tsx b/packages/grafana-ui/src/components/Spinner/Spinner.tsx index a77f80a9e5bbf..d2d3891d9d12e 100644 --- a/packages/grafana-ui/src/components/Spinner/Spinner.tsx +++ b/packages/grafana-ui/src/components/Spinner/Spinner.tsx @@ -31,7 +31,7 @@ export const Spinner: FC = (props: Props) => { const { className, inline = false, iconClassName, style, size = 16 } = props; const styles = getStyles(size, inline); return ( -
+
); diff --git a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx index d163a151672a9..3f118d24dc7d6 100644 --- a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx @@ -23,7 +23,7 @@ export function JSONViewCell(props: TableCellProps): JSX.Element { value = JSON.parse(value); } catch {} // ignore errors } else { - displayValue = JSON.stringify(value); + displayValue = JSON.stringify(value, null, ' '); } const content = ; diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 493c85862dd44..2df351259ac87 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -175,8 +175,6 @@ export const Table: FC = memo((props: Props) => { useResizeColumns ); - const { fields } = data; - const RenderRow = React.useCallback( ({ index: rowIndex, style }) => { const row = rows[rowIndex]; @@ -186,7 +184,6 @@ export const Table: FC = memo((props: Props) => { {row.cells.map((cell: Cell, index: number) => ( = memo((props: Props) => {
); }, - [fields, onCellFilterAdded, prepareRow, rows, tableStyles] + [onCellFilterAdded, prepareRow, rows, tableStyles] ); const headerHeight = noHeader ? 0 : tableStyles.cellHeight; diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index 97bc8a071c3b6..6c37370777370 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -1,20 +1,19 @@ import React, { FC } from 'react'; import { Cell } from 'react-table'; -import { Field } from '@grafana/data'; -import { TableFilterActionCallback } from './types'; +import { GrafanaTableColumn, TableFilterActionCallback } from './types'; import { TableStyles } from './styles'; export interface Props { cell: Cell; - field: Field; tableStyles: TableStyles; onCellFilterAdded?: TableFilterActionCallback; columnIndex: number; columnCount: number; } -export const TableCell: FC = ({ cell, field, tableStyles, onCellFilterAdded, columnIndex, columnCount }) => { +export const TableCell: FC = ({ cell, tableStyles, onCellFilterAdded, columnIndex, columnCount }) => { const cellProps = cell.getCellProps(); + const field = ((cell.column as any) as GrafanaTableColumn).field; if (!field.display) { return null; diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 8076dc48cf059..aa7480bb35e5b 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -1,7 +1,8 @@ -import { CellProps } from 'react-table'; -import { Field, KeyValue } from '@grafana/data'; +import { CellProps, Column, Row } from 'react-table'; +import { Field, KeyValue, SelectableValue } from '@grafana/data'; import { TableStyles } from './styles'; import { FC } from 'react'; +import { Property } from 'csstype'; export { TableFieldOptions, TableCellDisplayMode, FieldTextAlignment } from '@grafana/schema'; @@ -33,3 +34,11 @@ export interface TableCellProps extends CellProps { export type CellComponent = FC; export type FooterItem = Array> | string | undefined; + +export type GrafanaTableColumn = Column & { + field: Field; + sortType: 'number' | 'basic' | 'alphanumeric-insensitive'; + filter: (rows: Row[], id: string, filterValues?: SelectableValue[]) => SelectableValue[]; + justifyContent: Property.JustifyContent; + minWidth: number; +}; diff --git a/packages/grafana-ui/src/components/Table/utils.test.ts b/packages/grafana-ui/src/components/Table/utils.test.ts index a8319dcb0d8ac..f710638701cd9 100644 --- a/packages/grafana-ui/src/components/Table/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/utils.test.ts @@ -55,6 +55,13 @@ describe('Table utils', () => { expect(columns[0].width).toBe(450); expect(columns[1].width).toBe(100); }); + + it('Should set field on columns', () => { + const columns = getColumns(getData(), 1000, 120); + + expect(columns[0].field.name).toBe('Time'); + expect(columns[1].field.name).toBe('Value'); + }); }); describe('getTextAlign', () => { diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 79d9cee47873f..360e2e2f76f82 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -1,4 +1,4 @@ -import { Column, Row } from 'react-table'; +import { Row } from 'react-table'; import memoizeOne from 'memoize-one'; import { Property } from 'csstype'; import { @@ -12,7 +12,7 @@ import { import { DefaultCell } from './DefaultCell'; import { BarGaugeCell } from './BarGaugeCell'; -import { CellComponent, TableCellDisplayMode, TableFieldOptions, FooterItem } from './types'; +import { CellComponent, TableCellDisplayMode, TableFieldOptions, FooterItem, GrafanaTableColumn } from './types'; import { JSONViewCell } from './JSONViewCell'; import { ImageCell } from './ImageCell'; import { getFooterValue } from './FooterRow'; @@ -47,8 +47,8 @@ export function getColumns( availableWidth: number, columnMinWidth: number, footerValues?: FooterItem[] -): Column[] { - const columns: any[] = []; +): GrafanaTableColumn[] { + const columns: GrafanaTableColumn[] = []; let fieldCountWithoutWidth = 0; for (const [fieldIndex, field] of data.fields.entries()) { @@ -64,7 +64,7 @@ export function getColumns( fieldCountWithoutWidth++; } - const selectSortType = (type: FieldType): string => { + const selectSortType = (type: FieldType) => { switch (type) { case FieldType.number: return 'number'; @@ -79,13 +79,14 @@ export function getColumns( columns.push({ Cell, id: fieldIndex.toString(), + field: field, Header: getFieldDisplayName(field, data), accessor: (row: any, i: number) => { return field.values.get(i); }, sortType: selectSortType(field.type), width: fieldTableOptions.width, - minWidth: fieldTableOptions.minWidth || columnMinWidth, + minWidth: fieldTableOptions.minWidth ?? columnMinWidth, filter: memoizeOne(filterByValue(field)), justifyContent: getTextAlign(field), Footer: getFooterValue(fieldIndex, footerValues), diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 1bdeed1a79749..a7b7063b41c3b 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -61,6 +61,7 @@ const getTabStyles = stylesFactory((theme: GrafanaTheme2) => { display: flex; `, link: css` + width: 100%; color: ${theme.colors.text.secondary}; padding: ${theme.spacing(1.5, 2, 1)}; display: block; diff --git a/packages/grafana-ui/src/components/Tabs/TabContent.tsx b/packages/grafana-ui/src/components/Tabs/TabContent.tsx index 6168f0f67678c..28aad646fb3dc 100644 --- a/packages/grafana-ui/src/components/Tabs/TabContent.tsx +++ b/packages/grafana-ui/src/components/Tabs/TabContent.tsx @@ -1,5 +1,5 @@ import React, { FC, HTMLAttributes, ReactNode } from 'react'; -import { stylesFactory, useTheme2 } from '../../themes'; +import { useStyles2 } from '../../themes'; import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; @@ -7,17 +7,14 @@ interface Props extends HTMLAttributes { children: ReactNode; } -const getTabContentStyle = stylesFactory((theme: GrafanaTheme2) => { - return { - tabContent: css` - background: ${theme.colors.background.primary}; - `, - }; +const getTabContentStyle = (theme: GrafanaTheme2) => ({ + tabContent: css` + background: ${theme.colors.background.canvas}; + `, }); export const TabContent: FC = ({ children, className, ...restProps }) => { - const theme = useTheme2(); - const styles = getTabContentStyle(theme); + const styles = useStyles2(getTabContentStyle); return (
diff --git a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx index b5eb2a07206c1..adc6a207ba0b7 100644 --- a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx +++ b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx @@ -9,9 +9,11 @@ export interface Props { className?: string; /** For hiding the bottom border (on PageHeader for example) */ hideBorder?: boolean; + vertical?: boolean; + dataTestId?: string; } -const getTabsBarStyles = stylesFactory((theme: GrafanaTheme2, hideBorder = false) => { +const getTabsBarStyles = stylesFactory((theme: GrafanaTheme2, hideBorder = false, vertical = false) => { return { tabsWrapper: !hideBorder && @@ -22,19 +24,24 @@ const getTabsBarStyles = stylesFactory((theme: GrafanaTheme2, hideBorder = false position: relative; display: flex; height: 41px; + flex-direction: ${!!vertical ? 'column' : 'row'}; `, }; }); -export const TabsBar = React.forwardRef(({ children, className, hideBorder }, ref) => { - const theme = useTheme2(); - const tabsStyles = getTabsBarStyles(theme, hideBorder); +export const TabsBar = React.forwardRef( + ({ children, className, hideBorder, vertical = false, dataTestId = '' }, ref) => { + const theme = useTheme2(); + const tabsStyles = getTabsBarStyles(theme, hideBorder, vertical); - return ( -
-
    {children}
-
- ); -}); + return ( +
+
    + {children} +
+
+ ); + } +); TabsBar.displayName = 'TabsBar'; diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index aa7fba626af08..500c357a5ad7c 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -14,7 +14,6 @@ import { } from '@grafana/data'; import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../uPlot/config/UPlotConfigBuilder'; -import { FIXED_UNIT } from '../GraphNG/GraphNG'; import { AxisPlacement, GraphDrawStyle, @@ -24,11 +23,10 @@ import { ScaleDirection, ScaleOrientation, VizLegendOptions, - ScaleDistributionConfig, - ScaleDistribution, } from '@grafana/schema'; import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; import uPlot from 'uplot'; +import { buildScaleKey } from '../GraphNG/utils'; const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); @@ -146,17 +144,20 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor // The builder will manage unique scaleKeys and combine where appropriate builder.addScale( - tweakScale({ - scaleKey, - orientation: ScaleOrientation.Vertical, - direction: ScaleDirection.Up, - distribution: customConfig.scaleDistribution?.type, - log: customConfig.scaleDistribution?.log, - min: field.config.min, - max: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, - }) + tweakScale( + { + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + distribution: customConfig.scaleDistribution?.type, + log: customConfig.scaleDistribution?.log, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + }, + field + ) ); if (!yScaleKey) { @@ -165,15 +166,18 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor if (customConfig.axisPlacement !== AxisPlacement.Hidden) { builder.addAxis( - tweakAxis({ - scaleKey, - label: customConfig.axisLabel, - size: customConfig.axisWidth, - placement: customConfig.axisPlacement ?? AxisPlacement.Auto, - formatValue: (v) => formattedValueToString(fmt(v)), - theme, - grid: { show: customConfig.axisGridShow }, - }) + tweakAxis( + { + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement: customConfig.axisPlacement ?? AxisPlacement.Auto, + formatValue: (v) => formattedValueToString(fmt(v)), + theme, + grid: { show: customConfig.axisGridShow }, + }, + field + ) ); } @@ -303,7 +307,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor } if (stackingGroups.size !== 0) { - builder.setStacking(true); for (const [_, seriesIds] of stackingGroups.entries()) { const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); for (let j = seriesIdxs.length - 1; j > 0; j--) { @@ -316,11 +319,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor // hook up custom/composite renderers renderers?.forEach((r) => { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } let fieldIndices: Record = {}; for (let key in r.fieldMap) { let dispName = r.fieldMap[key]; - fieldIndices[key] = indexByName!.get(dispName)!; + fieldIndices[key] = indexByName.get(dispName)!; } r.init(builder, fieldIndices); @@ -435,34 +441,3 @@ export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): }); return originNames; } - -function buildScaleKey(config: FieldConfig) { - const defaultPart = 'na'; - - const scaleRange = `${config.min !== undefined ? config.min : defaultPart}-${ - config.max !== undefined ? config.max : defaultPart - }`; - - const scaleSoftRange = `${config.custom?.axisSoftMin !== undefined ? config.custom.axisSoftMin : defaultPart}-${ - config.custom?.axisSoftMax !== undefined ? config.custom.axisSoftMax : defaultPart - }`; - - const scalePlacement = `${config.custom?.axisPlacement !== undefined ? config.custom?.axisPlacement : defaultPart}`; - - const scaleUnit = config.unit ?? FIXED_UNIT; - - const scaleDistribution = config.custom?.scaleDistribution - ? getScaleDistributionPart(config.custom.scaleDistribution) - : ScaleDistribution.Linear; - - const scaleLabel = Boolean(config.custom?.axisLabel) ? config.custom!.axisLabel : defaultPart; - - return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}`; -} - -function getScaleDistributionPart(config: ScaleDistributionConfig) { - if (config.type === ScaleDistribution.Log) { - return `${config.type}${config.log}`; - } - return config.type; -} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 2aa052b4a897f..2d8ae40140f7d 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -178,7 +178,7 @@ export { MultiSelectValueEditor } from './OptionsUI/multiSelect'; export { Form } from './Forms/Form'; export { sharedInputStyle } from './Forms/commonStyles'; export { InputControl } from './InputControl'; -export { Button, LinkButton, ButtonVariant, ToolbarButton, ButtonGroup, ToolbarButtonRow } from './Button'; +export { Button, LinkButton, ButtonVariant, ToolbarButton, ButtonGroup, ToolbarButtonRow, ButtonProps } from './Button'; export { ValuePicker } from './ValuePicker/ValuePicker'; export { fieldMatchersUI } from './MatchersUI/fieldMatchersUI'; export { getFormStyles } from './Forms/getFormStyles'; @@ -201,6 +201,7 @@ export { selectOptionInTest } from './Select/test-utils'; export * from './Select/Select'; export { DropdownIndicator } from './Select/DropdownIndicator'; export { getSelectStyles } from './Select/getSelectStyles'; +export * from './Select/types'; export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout'; export { Badge, BadgeColor, BadgeProps } from './Badge/Badge'; @@ -265,7 +266,7 @@ export { UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder'; export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG'; export { TimeSeries } from './TimeSeries/TimeSeries'; export { useGraphNGContext } from './GraphNG/hooks'; -export { preparePlotFrame } from './GraphNG/utils'; +export { preparePlotFrame, buildScaleKey } from './GraphNG/utils'; export { GraphNGLegendEvent } from './GraphNG/types'; export * from './PanelChrome/types'; export { EmotionPerfTest } from './ThemeDemos/EmotionPerfTest'; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 80b245141f794..bac588eb8c7e6 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -75,7 +75,10 @@ export class UPlotAxisBuilder extends PlotConfigBuilder { (acc, value) => Math.max(acc, measureText(value, UPLOT_AXIS_FONT_SIZE).width), 0 ); - axisSize += axis!.gap! + axis!.labelGap! + maxTextWidth; + // limit y tick label width to 40% of visualization + const textWidthWithLimit = Math.min(self.width * 0.4, maxTextWidth); + // Not sure why this += and not normal assignment + axisSize += axis!.gap! + axis!.labelGap! + textWidthWithLimit; } return Math.ceil(axisSize); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index f409f93e48383..9d432d46cdeef 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -548,7 +548,6 @@ describe('UPlotConfigBuilder', () => { describe('Stacking', () => { it('allows stacking config', () => { const builder = new UPlotConfigBuilder(); - builder.setStacking(); builder.addSeries({ drawStyle: GraphDrawStyle.Line, scaleKey: 'scale-x', diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 00d11bebeff7f..b62fcf9bfe867 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -4,6 +4,7 @@ import { DataFrame, DefaultTimeZone, EventBus, + Field, getTimeZoneInfo, GrafanaTheme2, TimeRange, @@ -39,7 +40,6 @@ export class UPlotConfigBuilder { private scales: UPlotScaleBuilder[] = []; private bands: Band[] = []; private cursor: Cursor | undefined; - private isStacking = false; private select: uPlot.Select | undefined; private hasLeftAxis = false; private hooks: Hooks.Arrays = {}; @@ -121,10 +121,6 @@ export class UPlotConfigBuilder { this.select = select; } - setStacking(enabled = true) { - this.isStacking = enabled; - } - addSeries(props: SeriesProps) { this.series.push(new UPlotSeriesBuilder(props)); } @@ -223,24 +219,8 @@ export class UPlotConfigBuilder { config.tzDate = this.tzDate; config.padding = this.padding; - if (this.isStacking) { - // Let uPlot handle bands and fills + if (this.bands.length) { config.bands = this.bands; - } else { - // When fillBelowTo option enabled, handle series bands fill manually - if (this.bands?.length) { - config.bands = this.bands; - const keepFill = new Set(); - for (const b of config.bands) { - keepFill.add(b.series[0]); - } - - for (let i = 1; i < config.series.length; i++) { - if (!keepFill.has(i)) { - config.series[i].fill = undefined; - } - } - } } return config; @@ -287,8 +267,8 @@ type UPlotConfigPrepOpts = {}> = { eventBus: EventBus; allFrames: DataFrame[]; renderers?: Renderers; - tweakScale?: (opts: ScaleProps) => ScaleProps; - tweakAxis?: (opts: AxisProps) => AxisProps; + tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; + tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; } & T; /** @alpha */ diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts index e7bd4c24440c9..21029f98f076e 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts @@ -1,6 +1,6 @@ import { GrafanaTheme2, ThresholdsConfig, ThresholdsMode } from '@grafana/data'; import { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '@grafana/schema'; -import { getGradientRange, GradientDirection, scaleGradient } from './gradientFills'; +import { getGradientRange, scaleGradient } from './gradientFills'; import tinycolor from 'tinycolor2'; import uPlot from 'uplot'; @@ -86,7 +86,6 @@ export function getThresholdsDrawHook(options: UPlotThresholdOptions) { let grd = scaleGradient( u, u.series[1].scale!, - GradientDirection.Up, steps.map((step) => { let color = tinycolor(theme.visualization.getColorByName(step.color)); diff --git a/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts b/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts index 06a838efee9e3..c3daf47fb0ad7 100644 --- a/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts +++ b/packages/grafana-ui/src/components/uPlot/config/gradientFills.ts @@ -6,17 +6,45 @@ import { ThresholdsConfig, ThresholdsMode, } from '@grafana/data'; +import { ScaleOrientation } from '@grafana/schema'; import tinycolor from 'tinycolor2'; import uPlot from 'uplot'; import { getCanvasContext } from '../../../utils/measureText'; +function makeDirectionalGradient(direction: GradientDirection, bbox: uPlot.BBox, ctx: CanvasRenderingContext2D) { + let x0 = 0, + y0 = 0, + x1 = 0, + y1 = 0; + + if (direction === GradientDirection.Down) { + y0 = bbox.top; + y1 = bbox.top + bbox.height; + } else if (direction === GradientDirection.Left) { + x0 = bbox.left + bbox.width; + x1 = bbox.left; + } else if (direction === GradientDirection.Up) { + y0 = bbox.top + bbox.height; + y1 = bbox.top; + } else if (direction === GradientDirection.Right) { + x0 = bbox.left; + x1 = bbox.left + bbox.width; + } + + return ctx.createLinearGradient(x0, y0, x1, y1); +} + export function getOpacityGradientFn( color: string, opacity: number ): (self: uPlot, seriesIdx: number) => CanvasGradient { return (plot: uPlot, seriesIdx: number) => { const ctx = getCanvasContext(); - const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height); + const gradient = makeDirectionalGradient( + plot.scales.x!.ori === ScaleOrientation.Horizontal ? GradientDirection.Down : GradientDirection.Left, + plot.bbox, + ctx + ); gradient.addColorStop(0, colorManipulator.alpha(color, opacity)); gradient.addColorStop(1, colorManipulator.alpha(color, 0)); @@ -24,6 +52,7 @@ export function getOpacityGradientFn( return gradient; }; } + export function getHueGradientFn( color: string, opacity: number, @@ -31,7 +60,12 @@ export function getHueGradientFn( ): (self: uPlot, seriesIdx: number) => CanvasGradient { return (plot: uPlot, seriesIdx: number) => { const ctx = getCanvasContext(); - const gradient = ctx.createLinearGradient(0, plot.bbox.top, 0, plot.bbox.top + plot.bbox.height); + const gradient = makeDirectionalGradient( + plot.scales.x!.ori === ScaleOrientation.Horizontal ? GradientDirection.Down : GradientDirection.Left, + plot.bbox, + ctx + ); + const color1 = tinycolor(color).spin(-15); const color2 = tinycolor(color).spin(15); @@ -48,21 +82,17 @@ export function getHueGradientFn( } export enum GradientDirection { - 'Right' = 0, - 'Up' = 1, + Right = 0, + Up = 1, + Left = 2, + Down = 3, } type ValueStop = [value: number, color: string]; type ScaleValueStops = ValueStop[]; -export function scaleGradient( - u: uPlot, - scaleKey: string, - dir: GradientDirection, - scaleStops: ScaleValueStops, - discrete = false -) { +export function scaleGradient(u: uPlot, scaleKey: string, scaleStops: ScaleValueStops, discrete = false) { let scale = u.scales[scaleKey]; // we want the stop below or at the scaleMax @@ -106,7 +136,7 @@ export function scaleGradient( let x0, y0, x1, y1; - if (dir === GradientDirection.Up) { + if (u.scales.x!.ori === ScaleOrientation.Horizontal) { x0 = x1 = 0; y0 = minStopPos; y1 = maxStopPos; @@ -222,7 +252,7 @@ export function getScaleGradientFn( (step) => [step.value, colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity)] as ValueStop ); - gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true); + gradient = scaleGradient(plot, scaleKey, valueStops, true); } else { const [min, max] = getGradientRange(plot, scaleKey, hardMin, hardMax, softMin, softMax); const range = max - min; @@ -233,7 +263,7 @@ export function getScaleGradientFn( colorManipulator.alpha(theme.visualization.getColorByName(step.color), opacity), ] as ValueStop ); - gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, true); + gradient = scaleGradient(plot, scaleKey, valueStops, true); } } else if (colorMode.getColors) { const colors = colorMode.getColors(theme); @@ -246,7 +276,7 @@ export function getScaleGradientFn( colorManipulator.alpha(theme.visualization.getColorByName(color), opacity), ] as ValueStop ); - gradient = scaleGradient(plot, scaleKey, GradientDirection.Up, valueStops, false); + gradient = scaleGradient(plot, scaleKey, valueStops, false); } return gradient; diff --git a/packages/grafana-ui/src/themes/GlobalStyles/page.ts b/packages/grafana-ui/src/themes/GlobalStyles/page.ts index 1be34dcb752dc..cedd13eaf6e98 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/page.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/page.ts @@ -23,6 +23,7 @@ export function getPageStyles(theme: GrafanaTheme2) { flex-grow: 1; height: 100%; flex: 1 1 0; + overflow: auto; } .page-scrollbar-wrapper { @@ -38,6 +39,18 @@ export function getPageStyles(theme: GrafanaTheme2) { flex-direction: column; width: 100%; height: 100%; + + &.vertical-content > .page-container { + @media (min-width: ${theme.breakpoints.values.lg}px) { + margin-left: auto; + width: 70%; + } + + @media (min-width: ${maxWidthBreakpoint}px) { + margin-left: auto; + width: 50%; + } + } } .page-container { diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index ab016178cf48d..54b9cccf937dd 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -133,6 +133,7 @@ export const getAvailableIcons = () => 'presentation-play', 'process', 'question-circle', + 'record-audio', 'repeat', 'rocket', 'save', @@ -166,6 +167,30 @@ export const getAvailableIcons = () => 'users-alt', 'wrap-text', 'x', + 'percona-database', + 'percona-database-checks', + 'percona-analytics', + 'percona-cluster', + 'percona-cluster-network', + 'percona-cluster-computing', + 'percona-dashboard', + 'percona-kubernetes', + 'percona-summary', + 'percona-inventory', + 'percona-add', + 'percona-alert', + 'percona-disk', + 'percona-memory', + 'percona-temperature', + 'percona-cpu', + 'percona-surface', + 'percona-network', + 'percona-process', + 'percona-setting', + 'percona-bell', + 'percona-bell-slash', + 'pmm-logo', + 'qan-logo', ] as const; type BrandIconNames = 'google' | 'microsoft' | 'github' | 'gitlab' | 'okta'; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 52cbd388c4670..cecdb086f2bbf 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -12,8 +12,12 @@ export { ansicolor } from 'ansicolor'; import * as DOMUtil from './dom'; // includes Element.closest polyfill export { DOMUtil }; -export { renderOrCallToRender } from './renderOrCallToRender'; + export { createLogger } from './logger'; export { attachDebugger } from './debug'; export * from './nodeGraph'; export { fuzzyMatch } from './fuzzy'; + +// React utils +import * as ReactUtils from './reactUtils'; +export { ReactUtils }; diff --git a/packages/grafana-ui/src/utils/children.ts b/packages/grafana-ui/src/utils/reactUtils.ts similarity index 50% rename from packages/grafana-ui/src/utils/children.ts rename to packages/grafana-ui/src/utils/reactUtils.ts index d2556ea23e14d..33d9c8ed5a160 100644 --- a/packages/grafana-ui/src/utils/children.ts +++ b/packages/grafana-ui/src/utils/reactUtils.ts @@ -17,3 +17,24 @@ export function getChildId(children: ReactElement): string | undefined { return typeof inputId === 'string' ? inputId : undefined; } + +/** + * Given react node or function returns element accordingly + * + * @param itemToRender + * @param props props to be passed to the function if item provided as such + */ +export function renderOrCallToRender( + itemToRender: ((props?: TProps) => React.ReactNode) | React.ReactNode, + props?: TProps +): React.ReactNode { + if (React.isValidElement(itemToRender) || typeof itemToRender === 'string' || typeof itemToRender === 'number') { + return itemToRender; + } + + if (typeof itemToRender === 'function') { + return itemToRender(props); + } + + throw new Error(`${itemToRender} is not a React element nor a function that returns React element`); +} diff --git a/packages/grafana-ui/src/utils/renderOrCallToRender.ts b/packages/grafana-ui/src/utils/renderOrCallToRender.ts deleted file mode 100644 index b99d97dfda681..0000000000000 --- a/packages/grafana-ui/src/utils/renderOrCallToRender.ts +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -/** - * Given react node or function returns element accordingly - * - * @param itemToRender - * @param props props to be passed to the function if item provided as such - */ -export function renderOrCallToRender( - itemToRender: ((props?: TProps) => React.ReactNode) | React.ReactNode, - props?: TProps -): React.ReactNode { - if (React.isValidElement(itemToRender) || typeof itemToRender === 'string' || typeof itemToRender === 'number') { - return itemToRender; - } - - if (typeof itemToRender === 'function') { - return itemToRender(props); - } - - throw new Error(`${itemToRender} is not a React element nor a function that returns React element`); -} diff --git a/packages/grafana-ui/tsconfig.build.json b/packages/grafana-ui/tsconfig.build.json index 165673e82a251..6452db6164685 100644 --- a/packages/grafana-ui/tsconfig.build.json +++ b/packages/grafana-ui/tsconfig.build.json @@ -1,7 +1,7 @@ { "compilerOptions": { "paths": { - "@emotion/core": ["../../src/types/emotion-core-stub.d.ts"], + "@emotion/core": ["./src/types/emotion-core-stub.d.ts"], "@grafana/slate-react": ["slate-react"], "@grafana/ui": ["."] } diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index 22d879675648f..391e047a59a52 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@jaegertracing/jaeger-ui-components", - "version": "8.3.0-pre", + "version": "8.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "dependencies": { "@emotion/css": "11.1.3", "@emotion/react": "11.1.5", - "@grafana/data": "8.3.0-pre", - "@grafana/ui": "8.3.0-pre", + "@grafana/data": "8.3.10", + "@grafana/ui": "8.3.10", "chance": "^1.0.10", "classnames": "^2.2.5", "combokeys": "^3.0.0", diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js index d4ba4132d8248..e455fa1f36088 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js @@ -60,6 +60,11 @@ describe('', () => { }); }); + it('renders start time in header with millisecond precision', () => { + const startTimeMs = wrapper.find(LabeledList).props().items[0].value.props.children[1].props.children; + expect(startTimeMs).toMatch(/:\d\d\.\d\d\d/g); + }); + it('renders a ', () => { expect(wrapper.find(SpanGraph).length).toBe(1); }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx index f1c69988e2021..1e39a259fad89 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx @@ -167,7 +167,7 @@ export const HEADER_ITEMS = [ label: 'Trace Start', renderer(trace: Trace, timeZone: TimeZone, styles: ReturnType) { // Convert date from micro to milli seconds - const dateStr = dateTimeFormat(trace.startTime / 1000, { timeZone }); + const dateStr = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true }); const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); return match ? ( diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js index 73dea7818bccb..5c4c0e13f1ce0 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js @@ -65,7 +65,7 @@ describe('', () => { const spans = [ trace.spans[0], // this span is condidered to have collapsed children - { spanID: newSpanID, depth: 1 }, + { spanID: newSpanID, depth: 1, traceID: trace.traceID }, // these two "spans" are children and should be hidden { depth: 2 }, { depth: 3 }, @@ -227,20 +227,20 @@ describe('', () => { } it('works when nothing is expanded or collapsed', () => { - verify(0, `${trace.spans[0].spanID}--bar`); + verify(0, `${trace.spans[0].traceID}--${trace.spans[0].spanID}--bar`); }); it('works when rows are expanded', () => { expandRow(1); - verify(1, `${trace.spans[1].spanID}--bar`); - verify(2, `${trace.spans[1].spanID}--detail`); - verify(3, `${trace.spans[2].spanID}--bar`); + verify(1, `${trace.spans[1].traceID}--${trace.spans[1].spanID}--bar`); + verify(2, `${trace.spans[1].traceID}--${trace.spans[1].spanID}--detail`); + verify(3, `${trace.spans[2].traceID}--${trace.spans[2].spanID}--bar`); }); it('works when a parent span is collapsed', () => { const spans = addSpansAndCollapseTheirParent(); - verify(1, `${spans[1].spanID}--bar`); - verify(2, `${spans[4].spanID}--bar`); + verify(1, `${spans[1].traceID}--${spans[1].spanID}--bar`); + verify(2, `${spans[4].traceID}--${spans[4].spanID}--bar`); }); }); @@ -250,20 +250,20 @@ describe('', () => { } it('works when nothing is expanded or collapsed', () => { - verify(`${trace.spans[0].spanID}--bar`, 0); + verify(`${trace.traceID}--${trace.spans[0].spanID}--bar`, 0); }); it('works when rows are expanded', () => { expandRow(1); - verify(`${trace.spans[1].spanID}--bar`, 1); - verify(`${trace.spans[1].spanID}--detail`, 2); - verify(`${trace.spans[2].spanID}--bar`, 3); + verify(`${trace.spans[1].traceID}--${trace.spans[1].spanID}--bar`, 1); + verify(`${trace.spans[1].traceID}--${trace.spans[1].spanID}--detail`, 2); + verify(`${trace.spans[2].traceID}--${trace.spans[2].spanID}--bar`, 3); }); it('works when a parent span is collapsed', () => { const spans = addSpansAndCollapseTheirParent(); - verify(`${spans[1].spanID}--bar`, 1); - verify(`${spans[4].spanID}--bar`, 2); + verify(`${spans[1].traceID}--${spans[1].spanID}--bar`, 1); + verify(`${spans[4].traceID}--${spans[4].spanID}--bar`, 2); }); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx index bb6cf6003608b..a79cc4496a688 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -286,17 +286,18 @@ export class UnthemedVirtualizedTraceView extends React.Component { const { isDetail, span } = this.getRowStates()[index]; - return `${span.spanID}--${isDetail ? 'detail' : 'bar'}`; + return `${span.traceID}--${span.spanID}--${isDetail ? 'detail' : 'bar'}`; }; getIndexFromKey = (key: string) => { const parts = key.split('--'); - const _spanID = parts[0]; - const _isDetail = parts[1] === 'detail'; + const _traceID = parts[0]; + const _spanID = parts[1]; + const _isDetail = parts[2] === 'detail'; const max = this.getRowStates().length; for (let i = 0; i < max; i++) { const { span, isDetail } = this.getRowStates()[i]; - if (span.spanID === _spanID && isDetail === _isDetail) { + if (span.spanID === _spanID && span.traceID === _traceID && isDetail === _isDetail) { return i; } } diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index b0e529da403e6..012391b7cb4b0 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=alpine:3.14.2 +ARG BASE_IMAGE=alpine:3.15 FROM ${BASE_IMAGE} ARG GRAFANA_TGZ="grafana-latest.linux-x64-musl.tar.gz" diff --git a/packaging/docker/README.md b/packaging/docker/README.md index a16be49c7ec25..fcce71778f9b3 100644 --- a/packaging/docker/README.md +++ b/packaging/docker/README.md @@ -16,7 +16,35 @@ Further documentation can be found at http://docs.grafana.org/installation/docke ## Changelog -### v6.4.0-pre1 +### v8.3.0-beta2 + +- Our Alpine based images have been upgraded to Alpine [3.14.3](https://alpinelinux.org/posts/Alpine-3.14.3-released.html) [#41922](https://github.com/grafana/grafana/pull/41922) [@hairyhenderson](https://github.com/hairyhenderson) +- Our Go build image has been upgraded to Go [1.17.2](https://golang.org/doc/devel/release#go1.17.minor) [#41922](https://github.com/grafana/grafana/pull/41922) [@hairyhenderson](https://github.com/hairyhenderson) + +### v8.2.6 + +- **Security:** Upgrade Alpine based images to [3.14.3](https://alpinelinux.org/posts/Alpine-3.14.3-released.html). [#42061](https://github.com/grafana/grafana/pull/42061), [@dsotirakis](https://github.com/dsotirakis) +- **Security:** Upgrade Go to [1.17.2](https://go.dev/doc/devel/release#go1.17.minor). [#42427](https://github.com/grafana/grafana/pull/42427), [@idafurjes](https://github.com/idafurjes) + +### v7.3.0-beta1 + +- OpenShift compatability. [#27813](https://github.com/grafana/grafana/pull/27813), [@xlson](https://github.com/grafana/grafana/pull/27813) + +### v7.0.0-beta3 + +- Our Alpine based images have been upgraded to Alpine [3.11](https://www.alpinelinux.org/posts/Alpine-3.11.0-released.html) + +### v7.0.0-beta1 + +- Our Ubuntu based images have been upgraded to Ubuntu [20.04 LTS](https://releases.ubuntu.com/20.04/) + +### v6.5.0-beta1 + +- Build and publish an additional Ubuntu based docker image. [#20196](https://github.com/grafana/grafana/pull/20196), [@aknuds1](https://github.com/aknuds1) +- 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) +- Add additional glibc dependencies to support certain backend plugins in alpine. [#20214](https://github.com/grafana/grafana/pull/20214), [@briangann](https://github.com/briangann) + +### v6.4.0-beta1 - Switched base image from Ubuntu:18.04 to Alpine:3.10. diff --git a/packaging/docker/build.sh b/packaging/docker/build.sh index a14a20329f39a..533284ac8afd2 100755 --- a/packaging/docker/build.sh +++ b/packaging/docker/build.sh @@ -60,7 +60,7 @@ docker_build () { if [ $UBUNTU_BASE = "0" ]; then libc="-musl" dockerfile="Dockerfile" - base_image="${base_arch}alpine:3.14.2" + base_image="${base_arch}alpine:3.15" else libc="" dockerfile="ubuntu.Dockerfile" diff --git a/pkg/api/api.go b/pkg/api/api.go index d08edc1e5506c..6e995a6be7492 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -29,7 +29,7 @@ func (hs *HTTPServer) registerRoutes() { reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin - reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin) + reqCanAccessTeams := middleware.AdminOrEditorAndFeatureEnabled(hs.Cfg.EditorsCanAdmin) reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg) redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg) authorize := acmiddleware.Middleware(hs.AccessControl) @@ -52,8 +52,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/profile/password", reqSignedInNoAnonymous, hs.Index) r.Get("/.well-known/change-password", redirectToChangePassword) r.Get("/profile/switch-org/:id", reqSignedInNoAnonymous, hs.ChangeActiveOrgAndRedirectToHome) - r.Get("/org/", reqOrgAdmin, hs.Index) - r.Get("/org/new", reqGrafanaAdmin, hs.Index) + r.Get("/org/", authorize(reqOrgAdmin, orgPreferencesAccessEvaluator), hs.Index) + r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, orgsCreateAccessEvaluator), hs.Index) r.Get("/datasources/", authorize(reqOrgAdmin, dataSourcesConfigurationAccessEvaluator), hs.Index) r.Get("/datasources/new", authorize(reqOrgAdmin, dataSourcesNewAccessEvaluator), hs.Index) r.Get("/datasources/edit/*", authorize(reqOrgAdmin, dataSourcesEditAccessEvaluator), hs.Index) @@ -70,8 +70,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index) - r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) - r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index) + r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, orgsAccessEvaluator), hs.Index) + r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, orgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) @@ -254,6 +254,10 @@ func (hs *HTTPServer) registerRoutes() { // orgs (admin routes) apiRoute.Get("/orgs/name/:name/", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.GetOrgByName)) + // current api key + apiRoute.Group("/auth/key", func(keyRoute routing.RouteRegister) { + keyRoute.Get("/", routing.Wrap(GetAPIKeyCurrent)) + }) // auth api keys apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { keysRoute.Get("/", routing.Wrap(GetAPIKeys)) @@ -386,7 +390,7 @@ func (hs *HTTPServer) registerRoutes() { }) apiRoute.Get("/alert-notifiers", reqEditorRole, routing.Wrap( - GetAlertNotifiers(hs.Cfg.UnifiedAlerting.Enabled)), + GetAlertNotifiers(hs.Cfg.UnifiedAlerting.IsEnabled())), ) apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) { diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 4cecbf153594d..d7ec70636e201 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -11,6 +11,28 @@ import ( "github.com/grafana/grafana/pkg/models" ) +func GetAPIKeyCurrent(c *models.ReqContext) response.Response { + query := models.GetApiKeyByIdQuery{ApiKeyId: c.ApiKeyId} + + if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { + return response.Error(500, "Failed to list api keys", err) + } + + var expiration *time.Time = nil + if query.Result.Expires != nil { + v := time.Unix(*query.Result.Expires, 0) + expiration = &v + } + + return response.JSON(200, &models.ApiKeyDetailsDTO{ + Id: query.Result.Id, + OrgId: query.Result.OrgId, + Name: query.Result.Name, + Role: query.Result.Role, + Expiration: expiration, + }) +} + // GetAPIKeys returns a list of API keys func GetAPIKeys(c *models.ReqContext) response.Response { query := models.GetApiKeysQuery{OrgId: c.OrgId, IncludeExpired: c.QueryBool("includeExpired")} @@ -96,6 +118,10 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext, cmd models.AddApiKeyComman return response.Error(403, "Target service is not in the same organisation as requesting user or api key", err) } } + } else { + if cmd.CreateNewServiceAccount { + return response.Error(400, "Service accounts disabled. Retry create api request without service account flag.", err) + } } newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name) diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 59388d6c2939d..feba2f4e0c3ae 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -46,6 +46,7 @@ const ( WeightConfig WeightAdmin WeightProfile + WeightBackup WeightHelp ) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 2db53f1628c42..8ef0b667bccb8 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -155,7 +155,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i return nil, err } - pluginsToPreload := []*PreloadPlugin{} + pluginsToPreload := make([]*PreloadPlugin, 0) for _, app := range enabledPlugins[plugins.App] { if app.Preload { pluginsToPreload = append(pluginsToPreload, &PreloadPlugin{ @@ -176,14 +176,6 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i if isDefault, _ := dsM["isDefault"].(bool); isDefault { defaultDS = n } - - module, _ := dsM["module"].(string) - if preload, _ := dsM["preload"].(bool); preload && module != "" { - pluginsToPreload = append(pluginsToPreload, &PreloadPlugin{ - Path: module, - Version: dsM["info"].(map[string]interface{})["version"].(string), - }) - } } panels := map[string]interface{}{} @@ -192,13 +184,6 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i continue } - if panel.Preload { - pluginsToPreload = append(pluginsToPreload, &PreloadPlugin{ - Path: panel.Module, - Version: panel.Info.Version, - }) - } - panels[panel.ID] = map[string]interface{}{ "id": panel.ID, "module": panel.Module, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f68d7b816b7ea..eb3e025a6afdf 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -203,6 +203,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi hs.log.Debug("Using provided listener") } hs.registerRoutes() + hs.registerPerconaRoutes() if err := hs.declareFixedRoles(); err != nil { return nil, err @@ -419,6 +420,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { } m.Use(middleware.Recovery(hs.Cfg)) + m.UseMiddleware(middleware.CSRF(hs.Cfg.LoginCookieName, hs.log)) hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build") hs.mapStatic(m, hs.Cfg.StaticRootPath, "", "public") diff --git a/pkg/api/index.go b/pkg/api/index.go index 6af89d1eabfba..4ed005f17dee0 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -126,6 +126,10 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } if len(appLink.Children) > 0 { + // If we only have one child and it's the app default nav then remove it from children + if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url { + appLink.Children = []*dtos.NavLink{} + } appLinks = append(appLinks, appLink) } } @@ -168,6 +172,142 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm) + nodeSummaryChildNavs := []*dtos.NavLink{ + {Text: "CPU Utilization", Id: "cpu-utilization", Url: setting.AppSubUrl + "/d/node-cpu/cpu-utilization-details", Icon: "percona-cpu", HideFromTabs: true}, + {Text: "Disk", Id: "disk", Url: setting.AppSubUrl + "/d/node-disk/disk-details", Icon: "percona-disk", HideFromTabs: true}, + {Text: "Memory", Id: "memory", Url: setting.AppSubUrl + "/d/node-memory/memory-details", Icon: "percona-memory", HideFromTabs: true}, + {Text: "Network", Id: "network", Url: setting.AppSubUrl + "/d/node-network/network-details", Icon: "percona-network", HideFromTabs: true}, + {Text: "Temperature", Id: "temperature", Url: setting.AppSubUrl + "/d/node-temp/node-temperature-details", Icon: "percona-temperature", HideFromTabs: true}, + {Text: "NUMA", Id: "numa", Url: setting.AppSubUrl + "/d/node-memory-numa/numa-details", Icon: "percona-cluster-network", HideFromTabs: true}, + {Text: "Processes", Id: "processes", Url: setting.AppSubUrl + "/d/node-cpu-process/processes-details", Icon: "percona-process", HideFromTabs: true}, + } + + nodeChildNavs := []*dtos.NavLink{ + {Text: "Node Overview", Id: "node-overview", Url: setting.AppSubUrl + "/d/node-instance-overview/nodes-overview", Icon: "percona-cluster-network", HideFromTabs: true}, + {Text: "Node Summary", Id: "node-summary", Url: setting.AppSubUrl + "/d/node-instance-summary/node-summary", Icon: "percona-summary", HideFromTabs: true, Children: nodeSummaryChildNavs}, + } + + mysqlHAChildNavs := []*dtos.NavLink{ + {Text: "MySQL Group Replication Summary", Id: "mysql-group-replication-summary", Url: setting.AppSubUrl + "/d/mysql-group-replicaset-summary/mysql-group-replication-summary", Icon: "percona-cluster", HideFromTabs: true}, + {Text: "MySQL Replication Summary", Id: "mysql-replication-summary", Url: setting.AppSubUrl + "/d/mysql-replicaset-summary/mysql-replication-summary", Icon: "percona-cluster", HideFromTabs: true}, + {Text: "PXC/Galera Cluster Summary", Id: "pxc-cluster-summary", Url: setting.AppSubUrl + "/d/pxc-cluster-summary/pxc-galera-cluster-summary", Icon: "percona-cluster", HideFromTabs: true}, + {Text: "PXC/Galera Node Summary", Id: "pxc-node-summary", Url: setting.AppSubUrl + "/d/pxc-node-summary/pxc-galera-node-summary", Icon: "percona-cluster", HideFromTabs: true}, + {Text: "PXC/Galera Nodes Compare", Id: "pxc-nodes-compare", Url: setting.AppSubUrl + "/d/pxc-nodes-compare/pxc-galera-nodes-compare", Icon: "percona-cluster", HideFromTabs: true}, + } + + mysqlSummaryChildNavs := []*dtos.NavLink{ + { + Text: "MySQL Command/Handler Counters Compare", + Id: "mysql-command-handler-counters-compare", + Url: setting.AppSubUrl + "/d/mysql-commandhandler-compare/mysql-command-handler-counters-compare", + Icon: "sitemap", + }, + { + Text: "MySQL InnoDB Compression Details", + Id: "mysql-innodb-compression-details", + Url: setting.AppSubUrl + "/d/mysql-innodb-compression/mysql-innodb-compression-details", + Icon: "sitemap", + }, + { + Text: "MySQL InnoDB Details", + Id: "mysql-innodb-details", + Url: setting.AppSubUrl + "/d/mysql-innodb/mysql-innodb-details", + Icon: "sitemap", + }, + { + Text: "MySQL Performance Schema Details", + Id: "mysql-performance-schema-details", + Url: setting.AppSubUrl + "/d/mysql-performance-schema/mysql-performance-schema-details", + Icon: "sitemap", + }, + { + Text: "MySQL Query Response Time Details", + Id: "mysql-query-response-time-details", + Url: setting.AppSubUrl + "/d/mysql-queryresponsetime/mysql-query-response-time-details", + Icon: "sitemap", + }, + { + Text: "MySQL Table Details", + Id: "mysql-table-details", + Url: setting.AppSubUrl + "/d/mysql-table/mysql-table-details", + Icon: "sitemap", + }, + { + Text: "MySQL TokuDB Details", + Id: "mysql-tokudb-details", + Url: setting.AppSubUrl + "/d/mysql-tokudb/mysql-tokudb-details", + Icon: "sitemap", + }, + { + Text: "MySQL User Details", + Id: "mysql-user-details", + Url: setting.AppSubUrl + "/d/mysql-user/mysql-user-details", + Icon: "sitemap", + }, + { + Text: "MySQL Wait Event Analyses Details", + Id: "mysql-wait-event-analyses-details", + Url: setting.AppSubUrl + "/d/mysql-waitevents-analysis/mysql-wait-event-analyses-details", + Icon: "sitemap", + }, + { + Text: "MySQL MyISAM/Aria Details", + Id: "mysql-myisam-aria-details", + Url: setting.AppSubUrl + "/d/mysql-myisamaria/mysql-myisam-aria-details", + Icon: "sitemap", + }, + { + Text: "MySQL MyRocks Details", + Id: "mysql-myrocks-details", + Url: setting.AppSubUrl + "/d/mysql-myrocks/mysql-myrocks-details", + Icon: "sitemap", + }, + { + Text: "MySQL Amazon Aurora Details", + Id: "mysql-amazon-aurora-details", + Url: setting.AppSubUrl + "/d/mysql-amazonaurora/mysql-amazon-aurora-details", + Icon: "sitemap", + }, + } + + mysqlChildNavs := []*dtos.NavLink{ + {Text: "HA (High availability)", Id: "mysql-ha", Icon: "percona-cluster", HideFromTabs: true, Children: mysqlHAChildNavs}, + {Text: "MySQL Overview", Id: "mysql-overview", Url: setting.AppSubUrl + "/d/mysql-instance-overview/mysql-instances-overview", Icon: "percona-cluster-network", HideFromTabs: true}, + {Text: "MySQL Summary", Id: "mysql-summary", Url: setting.AppSubUrl + "/d/mysql-instance-summary/mysql-instances-summary", Icon: "percona-summary", HideFromTabs: true, Children: mysqlSummaryChildNavs}, + } + + mongodbHAChildNavs := []*dtos.NavLink{ + {Text: "MongoDB Cluster Summary", Id: "mongo-cluster-summary", Url: setting.AppSubUrl + "/d/mongodb-cluster-summary/mongodb-cluster-summary", Icon: "percona-cluster", HideFromTabs: true}, + {Text: "MongoDB ReplSet Summary", Id: "mongo-rplset-summary", Url: setting.AppSubUrl + "/d/mongodb-replicaset-summary/mongodb-replset-summary", Icon: "percona-cluster", HideFromTabs: true}, + } + + mongodbSummaryChildNavs := []*dtos.NavLink{ + {Text: "MongoDB InMemory Details", Id: "mongo-memory-details", Url: setting.AppSubUrl + "/d/mongodb-inmemory/mongodb-inmemory-details", Icon: "sitemap", HideFromTabs: true}, + {Text: "MongoDB MMAPv1 Details", Id: "mongo-mmap-details", Url: setting.AppSubUrl + "/d/mongodb-mmapv1/mongodb-mmapv1-details", Icon: "sitemap", HideFromTabs: true}, + {Text: "MongoDB WiredTiger Details", Id: "mondo-wiredtiger-details", Url: setting.AppSubUrl + "/d/mongodb-wiredtiger/mongodb-wiredtiger-details", Icon: "sitemap", HideFromTabs: true}, + } + + mongodbChildNavs := []*dtos.NavLink{ + {Text: "HA (High availability)", Id: "mongo-ha", Icon: "percona-cluster", HideFromTabs: true, Children: mongodbHAChildNavs}, + {Text: "MongoDB Overview", Id: "mongo-overview", Url: setting.AppSubUrl + "/d/mongodb-instance-overview/mongodb-instances-overview", Icon: "percona-cluster-network", HideFromTabs: true}, + {Text: "MongoDB Summary", Id: "mongo-summary", Url: setting.AppSubUrl + "/d/mongodb-instance-summary/mongodb-instance-summary", Icon: "percona-summary", HideFromTabs: true, Children: mongodbSummaryChildNavs}, + } + + postgresqlChildNavs := []*dtos.NavLink{ + // {Text: "HA (High availability)", Id: "postgres-ha", Icon: "percona-cluster", HideFromTabs: true}, + {Text: "PostgreSQL Overview", Id: "postgre-overwiew", Url: setting.AppSubUrl + "/d/postgresql-instance-overview/postgresql-instances-overview", Icon: "percona-cluster-network", HideFromTabs: true}, + {Text: "PostgreSQL Summary", Id: "postgre-summary", Url: setting.AppSubUrl + "/d/postgresql-instance-summary/postgresql-instances-summary", Icon: "percona-summary", HideFromTabs: true}, + } + + pmmChildNavs := []*dtos.NavLink{ + {Text: "System (Node)", Id: "system", Url: setting.AppSubUrl + "/d/node-instance-overview/nodes-overview", Icon: "percona-cluster-network", HideFromTabs: true, Children: nodeChildNavs}, + {Text: "MySQL", Id: "mysql", Url: setting.AppSubUrl + "/d/mysql-instance-overview/mysql-instances-overview", Icon: "percona-database", HideFromTabs: true, Children: mysqlChildNavs}, + {Text: "MongoDB", Id: "mongo", Url: setting.AppSubUrl + "/d/mongodb-instance-overview/mongodb-instances-overview", Icon: "percona-database", HideFromTabs: true, Children: mongodbChildNavs}, + {Text: "PostgreSQL", Id: "postgre", Url: setting.AppSubUrl + "/d/postgresql-instance-overview/postgresql-instances-overview", Icon: "percona-database", HideFromTabs: true, Children: postgresqlChildNavs}, + {Text: "ProxySQL", Id: "proxysql", Url: setting.AppSubUrl + "/d/proxysql-instance-summary/proxysql-instance-summary", Icon: "percona-database", HideFromTabs: true}, + {Text: "HAProxy", Id: "haproxy", Url: setting.AppSubUrl + "/d/haproxy-instance-summary/haproxy-instance-summary", Icon: "percona-database", HideFromTabs: true}, + } + dashboardsUrl := "/" if hs.Cfg.IsNewNavigationEnabled() { dashboardsUrl = "/dashboards" @@ -188,6 +328,27 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto return c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR || setting.ViewersCanEdit } + navTree = append(navTree, &dtos.NavLink{ + Text: "PMM dashboards", + Id: "pmm", + SubTitle: "Manage dashboards & folders", + Icon: "percona-dashboard", + Url: setting.AppSubUrl + "/", + SortWeight: dtos.WeightDashboard, + Section: dtos.NavSectionCore, + Children: pmmChildNavs, + }) + + navTree = append(navTree, &dtos.NavLink{ + Text: "Query Analytics (QAN)", + Id: "qan", + Icon: "qan-logo", + Url: setting.AppSubUrl + "/d/pmm-qan/pmm-query-analytics", + SortWeight: dtos.WeightDashboard, + Section: dtos.NavSectionCore, + HideFromTabs: true, + }) + if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) { navTree = append(navTree, &dtos.NavLink{ Text: "Explore", @@ -205,9 +366,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto } _, uaIsDisabledForOrg := hs.Cfg.UnifiedAlerting.DisabledOrgs[c.OrgId] - uaVisibleForOrg := hs.Cfg.UnifiedAlerting.Enabled && !uaIsDisabledForOrg + uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg - if setting.AlertingEnabled || uaVisibleForOrg { + if setting.AlertingEnabled != nil && *setting.AlertingEnabled || uaVisibleForOrg { alertChildNavs := hs.buildAlertNavLinks(c, uaVisibleForOrg) navTree = append(navTree, &dtos.NavLink{ Text: "Alerting", @@ -267,7 +428,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Icon: "plug", Url: hs.Cfg.AppSubURL + "/plugins", }) + } + if hasAccess(ac.ReqOrgAdmin, orgPreferencesAccessEvaluator) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Preferences", Id: "org-settings", @@ -275,6 +438,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Icon: "sliders-v-alt", Url: hs.Cfg.AppSubURL + "/org", }) + } + + if c.OrgRole == models.ROLE_ADMIN { configNodes = append(configNodes, &dtos.NavLink{ Text: "API keys", Id: "apikeys", @@ -329,12 +495,11 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto adminNavLinks := hs.buildAdminNavLinks(c) if len(adminNavLinks) > 0 { - serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks) + navSection := dtos.NavSectionCore if hs.Cfg.IsNewNavigationEnabled() { - serverAdminNode.Section = dtos.NavSectionConfig - } else { - serverAdminNode.Section = dtos.NavSectionCore + navSection = dtos.NavSectionConfig } + serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks, navSection) navTree = append(navTree, serverAdminNode) } @@ -415,10 +580,7 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg b alertChildNavs := []*dtos.NavLink{ {Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul"}, } - if uaVisibleForOrg { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Alert groups", Id: "groups", Url: hs.Cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) - alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"}) - } + if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR { if uaVisibleForOrg { alertChildNavs = append(alertChildNavs, &dtos.NavLink{ @@ -433,6 +595,10 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg b }) } } + if uaVisibleForOrg { + alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"}) + alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Alert groups", Id: "groups", Url: hs.Cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) + } if c.OrgRole == models.ROLE_ADMIN && uaVisibleForOrg { alertChildNavs = append(alertChildNavs, &dtos.NavLink{ Text: "Admin", Id: "alerting-admin", Url: hs.Cfg.AppSubURL + "/alerting/admin", @@ -458,9 +624,9 @@ func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink }) _, uaIsDisabledForOrg := hs.Cfg.UnifiedAlerting.DisabledOrgs[c.OrgId] - uaVisibleForOrg := hs.Cfg.UnifiedAlerting.Enabled && !uaIsDisabledForOrg + uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg - if setting.AlertingEnabled || uaVisibleForOrg { + if setting.AlertingEnabled != nil && *setting.AlertingEnabled || uaVisibleForOrg { children = append(children, &dtos.NavLink{ Text: "Alert rule", SubTitle: "Create an alert rule", Id: "alert", Icon: "bell", Url: hs.Cfg.AppSubURL + "/alerting/new", @@ -472,6 +638,7 @@ func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { hasAccess := ac.HasAccess(hs.AccessControl, c) + hasGlobalAccess := ac.HasGlobalAccess(hs.AccessControl, c) adminNavLinks := []*dtos.NavLink{} if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { @@ -480,7 +647,7 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { }) } - if c.IsGrafanaAdmin { + if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Orgs", Id: "global-orgs", Url: hs.Cfg.AppSubURL + "/admin/orgs", Icon: "building", }) diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index bba65c70257a9..aff5ef20b78e4 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -217,6 +217,11 @@ func (hs *HTTPServer) PostSyncUserWithLDAP(c *models.ReqContext) response.Respon ReqContext: c, ExternalUser: user, SignupAllowed: hs.Cfg.LDAPAllowSignup, + UserLookupParams: models.UserLookupParams{ + UserID: &query.Result.Id, // Upsert by ID only + Email: nil, + Login: nil, + }, } err = bus.Dispatch(upsertCmd) diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 436d8878e21b5..40dab99355161 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -101,7 +101,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { code := ctx.Query("code") if code == "" { - opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOnline} + opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} if provider.UsePKCE { ascii, pkce, err := genPKCECode() @@ -310,6 +310,11 @@ func syncUser( ReqContext: ctx, ExternalUser: extUser, SignupAllowed: connect.IsSignupAllowed(), + UserLookupParams: models.UserLookupParams{ + Email: &extUser.Email, + UserID: nil, + Login: nil, + }, } if err := bus.Dispatch(cmd); err != nil { return nil, err diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index 369088820ab67..87ee1001a9ca7 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -18,6 +19,11 @@ import ( "github.com/grafana/grafana/pkg/tsdb/legacydata" ) +const ( + headerName = "httpHeaderName" + headerValue = "httpHeaderValue" +) + // QueryMetricsV2 returns query metrics. // POST /api/ds/query DataSource query w/ expressions func (hs *HTTPServer) QueryMetricsV2(c *models.ReqContext, reqDTO dtos.MetricRequest) response.Response { @@ -230,9 +236,18 @@ func (hs *HTTPServer) createRequest(ctx context.Context, ds *models.DataSource, if token := hs.OAuthTokenService.GetCurrentOAuthToken(ctx, query.User); token != nil { delete(query.Headers, "Authorization") query.Headers["Authorization"] = fmt.Sprintf("%s %s", token.Type(), token.AccessToken) + + idToken, ok := token.Extra("id_token").(string) + if ok && idToken != "" { + query.Headers["X-ID-Token"] = idToken + } } } + for k, v := range customHeaders(ds.JsonData, instanceSettings.DecryptedSecureJSONData) { + query.Headers[k] = v + } + req := &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ OrgID: ds.OrgId, @@ -264,3 +279,23 @@ func (hs *HTTPServer) createRequest(ctx context.Context, ds *models.DataSource, return req, nil } + +func customHeaders(jsonData *simplejson.Json, decryptedJsonData map[string]string) map[string]string { + if jsonData == nil { + return nil + } + + data := jsonData.MustMap() + + headers := map[string]string{} + for k := range data { + if strings.HasPrefix(k, headerName) { + if header, ok := data[k].(string); ok { + valueKey := strings.ReplaceAll(k, headerName, headerValue) + headers[header] = decryptedJsonData[valueKey] + } + } + } + + return headers +} diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go new file mode 100644 index 0000000000000..25366d035362a --- /dev/null +++ b/pkg/api/metrics_test.go @@ -0,0 +1,237 @@ +package api_test + +import ( + "context" + "net/http" + "testing" + + "gopkg.in/macaron.v1" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + + "github.com/grafana/grafana/pkg/web" + + "github.com/grafana/grafana/pkg/api/routing" + + "github.com/grafana/grafana/pkg/services/quota" + + "github.com/grafana/grafana/pkg/setting" + + "golang.org/x/oauth2" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/api" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/stretchr/testify/require" +) + +func TestQueryData(t *testing.T) { + t.Run("it attaches custom headers to the request", func(t *testing.T) { + tc := setup() + tc.dataSourceCache.ds.JsonData = simplejson.NewFromAny(map[string]interface{}{"httpHeaderName1": "foo", "httpHeaderName2": "bar"}) + tc.secretService.decryptedJson = map[string]string{"httpHeaderValue1": "test-header", "httpHeaderValue2": "test-header2"} + + _ = tc.httpServer.QueryMetricsV2(requestContext, metricRequest()) + + require.Equal(t, map[string]string{"foo": "test-header", "bar": "test-header2"}, tc.pluginContext.req.Headers) + }) + + t.Run("it attaches auth headers to the request", func(t *testing.T) { + token := &oauth2.Token{ + TokenType: "bearer", + AccessToken: "access-token", + } + token = token.WithExtra(map[string]interface{}{"id_token": "id-token"}) + + tc := setup() + tc.oauthTokenService.passThruEnabled = true + tc.oauthTokenService.token = token + + _ = tc.httpServer.QueryMetricsV2(requestContext, metricRequest()) + + expected := map[string]string{ + "Authorization": "Bearer access-token", + "X-ID-Token": "id-token", + } + require.Equal(t, expected, tc.pluginContext.req.Headers) + }) +} + +func setup() *testContext { + pc := &fakePluginClient{} + sc := &fakeSecretsService{} + dc := &fakeDataSourceCache{ds: &models.DataSource{JsonData: simplejson.New()}} + tc := &fakeOAuthTokenService{} + rv := &fakePluginRequestValidator{} + + server, err := api.ProvideHTTPServer( + api.ServerOptions{}, + &setting.Cfg{}, + &noOpRouteRegister{}, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + rv, + nil, + nil, + nil, + pc, + nil, + nil, + dc, + nil, + nil, + nil, + nil, + nil, + nil, + &fakeAccessControl{}, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + "a.QuotaService{}, + nil, + tc, + nil, + nil, + nil, + nil, + sc, + nil, + ) + if err != nil { + panic(err) + } + + return &testContext{ + pluginContext: pc, + secretService: sc, + dataSourceCache: dc, + oauthTokenService: tc, + pluginRequestValidator: rv, + httpServer: server, + } +} + +type testContext struct { + pluginContext *fakePluginClient + secretService *fakeSecretsService + dataSourceCache *fakeDataSourceCache + oauthTokenService *fakeOAuthTokenService + pluginRequestValidator *fakePluginRequestValidator + httpServer *api.HTTPServer +} + +func metricRequest() dtos.MetricRequest { + q, _ := simplejson.NewJson([]byte(`{"datasourceId":1}`)) + return dtos.MetricRequest{ + From: "", + To: "", + Queries: []*simplejson.Json{q}, + Debug: false, + } +} + +var requestContext = &models.ReqContext{ + Context: &macaron.Context{ + Req: &http.Request{}, + }, +} + +type fakeAccessControl struct { + accesscontrol.AccessControl +} + +func (ac *fakeAccessControl) IsDisabled() bool { + return true +} + +func (ac *fakeAccessControl) DeclareFixedRoles(...accesscontrol.RoleRegistration) error { + return nil +} + +type noOpRouteRegister struct{} + +func (noOpRouteRegister) Get(string, ...web.Handler) {} +func (noOpRouteRegister) Post(string, ...web.Handler) {} +func (noOpRouteRegister) Delete(string, ...web.Handler) {} +func (noOpRouteRegister) Put(string, ...web.Handler) {} +func (noOpRouteRegister) Patch(string, ...web.Handler) {} +func (noOpRouteRegister) Any(string, ...web.Handler) {} +func (noOpRouteRegister) Group(string, func(routing.RouteRegister), ...web.Handler) {} +func (noOpRouteRegister) Insert(string, func(routing.RouteRegister), ...web.Handler) {} +func (noOpRouteRegister) Register(routing.Router) {} +func (noOpRouteRegister) Reset() {} + +type fakePluginRequestValidator struct { + err error +} + +func (rv *fakePluginRequestValidator) Validate(dsURL string, req *http.Request) error { + return rv.err +} + +type fakeOAuthTokenService struct { + passThruEnabled bool + token *oauth2.Token +} + +func (ts *fakeOAuthTokenService) GetCurrentOAuthToken(context.Context, *models.SignedInUser) *oauth2.Token { + return ts.token +} + +func (ts *fakeOAuthTokenService) IsOAuthPassThruEnabled(*models.DataSource) bool { + return ts.passThruEnabled +} + +type fakeSecretsService struct { + secrets.Service + + decryptedJson map[string]string +} + +func (s *fakeSecretsService) DecryptJsonData(ctx context.Context, sjd map[string][]byte) (map[string]string, error) { + return s.decryptedJson, nil +} + +type fakeDataSourceCache struct { + ds *models.DataSource +} + +func (c *fakeDataSourceCache) GetDatasource(datasourceID int64, user *models.SignedInUser, skipCache bool) (*models.DataSource, error) { + return c.ds, nil +} + +func (c *fakeDataSourceCache) GetDatasourceByUID(datasourceUID string, user *models.SignedInUser, skipCache bool) (*models.DataSource, error) { + return c.ds, nil +} + +type fakePluginClient struct { + plugins.Client + + req *backend.QueryDataRequest +} + +func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + c.req = req + return &backend.QueryDataResponse{}, nil +} diff --git a/pkg/api/navlinks/navlinks.go b/pkg/api/navlinks/navlinks.go index 3ebe6cdee2626..6d7ddfa535b49 100644 --- a/pkg/api/navlinks/navlinks.go +++ b/pkg/api/navlinks/navlinks.go @@ -2,7 +2,7 @@ package navlinks import "github.com/grafana/grafana/pkg/api/dtos" -func GetServerAdminNode(children []*dtos.NavLink) *dtos.NavLink { +func GetServerAdminNode(children []*dtos.NavLink, navSection string) *dtos.NavLink { url := "" if len(children) > 0 { url = children[0].Url @@ -15,6 +15,7 @@ func GetServerAdminNode(children []*dtos.NavLink) *dtos.NavLink { Icon: "shield", Url: url, SortWeight: dtos.WeightAdmin, + Section: navSection, Children: children, } } diff --git a/pkg/api/percona_api.go b/pkg/api/percona_api.go new file mode 100644 index 0000000000000..1cb92c00325e0 --- /dev/null +++ b/pkg/api/percona_api.go @@ -0,0 +1,20 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/middleware" +) + +func (hs *HTTPServer) registerPerconaRoutes() { + reqSignedIn := middleware.ReqSignedIn + reqSignedInNoAnonymous := middleware.ReqSignedInNoAnonymous + + r := hs.RouteRegister + + r.Group("/percona-api", func(apiRoute routing.RouteRegister) { + apiRoute.Group("/user", func(userRoute routing.RouteRegister) { + userRoute.Get("/oauth-token", routing.Wrap(hs.GetUserOAuthToken)) + }, reqSignedInNoAnonymous) + apiRoute.Get("/saas-host", routing.Wrap(GetPerconaSaasHost)) + }, reqSignedIn) +} diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 5b1806cf7b7e0..bf04c5b6fc8bc 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -54,6 +54,7 @@ func (t *handleResponseTransport) RoundTrip(req *http.Request) (*http.Response, return nil, err } res.Header.Del("Set-Cookie") + proxyutil.SetProxyResponseHeaders(res.Header) return res, nil } @@ -269,6 +270,11 @@ func (proxy *DataSourceProxy) director(req *http.Request) { if proxy.oAuthTokenService.IsOAuthPassThruEnabled(proxy.ds) { if token := proxy.oAuthTokenService.GetCurrentOAuthToken(proxy.ctx.Req.Context(), proxy.ctx.SignedInUser); token != nil { req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type(), token.AccessToken)) + + idToken, ok := token.Extra("id_token").(string) + if ok && idToken != "" { + req.Header.Set("X-ID-Token", idToken) + } } } } diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 846c551d8ebaa..b03b4e461a11c 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -487,15 +487,22 @@ func TestDataSourceProxy_routeRule(t *testing.T) { SignedInUser: &models.SignedInUser{UserId: 1}, Context: &web.Context{Req: req}, } + + token := &oauth2.Token{ + AccessToken: "testtoken", + RefreshToken: "testrefreshtoken", + TokenType: "Bearer", + Expiry: time.Now().AddDate(0, 0, 1), + } + extra := map[string]interface{}{ + "id_token": "testidtoken", + } + token = token.WithExtra(extra) mockAuthToken := mockOAuthTokenService{ - token: &oauth2.Token{ - AccessToken: "testtoken", - RefreshToken: "testrefreshtoken", - TokenType: "Bearer", - Expiry: time.Now().AddDate(0, 0, 1), - }, + token: token, oAuthEnabled: true, } + var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) dsService := datasources.ProvideService(bus.New(), nil, secretsService) @@ -507,6 +514,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { proxy.director(req) assert.Equal(t, "Bearer testtoken", req.Header.Get("Authorization")) + assert.Equal(t, "testidtoken", req.Header.Get("X-ID-Token")) }) t.Run("When SendUserHeader config is enabled", func(t *testing.T) { @@ -655,6 +663,20 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { assert.Equal(t, "important_cookie=important_value", proxy.ctx.Resp.Header().Get("Set-Cookie")) }) + t.Run("When response should set Content-Security-Policy header", func(t *testing.T) { + ctx, ds := setUp(t) + var routes []*plugins.Route + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + dsService := datasources.ProvideService(bus.New(), nil, secretsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + require.NoError(t, err) + + proxy.HandleRequest() + + require.NoError(t, writeErr) + assert.Equal(t, "sandbox", proxy.ctx.Resp.Header().Get("Content-Security-Policy")) + }) + t.Run("Data source returns status code 401", func(t *testing.T) { ctx, ds := setUp(t, setUpCfg{ writeCb: func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index a972d3d500a2a..f66cc32ddac0a 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -83,5 +83,11 @@ func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins. } } - return &httputil.ReverseProxy{Director: director} + return &httputil.ReverseProxy{Director: director, ModifyResponse: modifyResponse} +} + +func modifyResponse(resp *http.Response) error { + proxyutil.SetProxyResponseHeaders(resp.Header) + + return nil } diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index e6585f038a689..0aaf1d0d1b356 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -4,6 +4,7 @@ import ( "context" "io/ioutil" "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana/pkg/bus" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/macaron.v1" @@ -245,6 +247,41 @@ func TestPluginProxy(t *testing.T) { require.NoError(t, err) require.Equal(t, `{ "url": "https://dynamic.grafana.com", "secret": "123" }`, string(content)) }) + + t.Run("When proxying a request should set expected response headers", func(t *testing.T) { + requestHandled := false + backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte("I am the backend")) + requestHandled = true + })) + t.Cleanup(backendServer.Close) + + responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) + + route := &plugins.Route{ + Path: "/", + URL: backendServer.URL, + } + + ctx := &models.ReqContext{ + SignedInUser: &models.SignedInUser{}, + Context: &web.Context{ + Req: httptest.NewRequest("GET", "/", nil), + Resp: responseWriter, + }, + } + proxy := NewApiPluginProxy(ctx, "", route, "", &setting.Cfg{}, secretsService) + proxy.ServeHTTP(ctx.Resp, ctx.Req) + + for { + if requestHandled { + break + } + } + + require.Equal(t, "sandbox", ctx.Resp.Header().Get("Content-Security-Policy")) + }) } // getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index aa889a4bd8f45..7cdc4641caa97 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -281,14 +281,27 @@ func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) { return } - requestedFile := filepath.Clean(web.Params(c.Req)["*"]) - pluginFilePath := filepath.Join(plugin.PluginDir, requestedFile) + // prepend slash for cleaning relative paths + requestedFile := filepath.Clean(filepath.Join("/", web.Params(c.Req)["*"])) + rel, err := filepath.Rel("/", requestedFile) + if err != nil { + // slash is prepended above therefore this is not expected to fail + c.JsonApiErr(500, "Failed to get the relative path", err) + return + } - if !plugin.IncludedInSignature(requestedFile) { + if !plugin.IncludedInSignature(rel) { hs.log.Warn("Access to requested plugin file will be forbidden in upcoming Grafana versions as the file "+ "is not included in the plugin signature", "file", requestedFile) } + absPluginDir, err := filepath.Abs(plugin.PluginDir) + if err != nil { + c.JsonApiErr(500, "Failed to get plugin absolute path", nil) + return + } + + pluginFilePath := filepath.Join(absPluginDir, rel) // It's safe to ignore gosec warning G304 since we already clean the requested file path and subsequently // use this with a prefix of the plugin's directory, which is set during plugin loading // nolint:gosec @@ -465,15 +478,15 @@ func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginId string, name } // nolint:gosec - // We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based - // on plugin the folder structure on disk and not user input. - path := filepath.Join(plugin.PluginDir, fmt.Sprintf("%s.md", strings.ToUpper(name))) + // We can ignore the gosec G304 warning since we have cleaned the requested file path and subsequently + // use this with a prefix of the plugin's directory, which is set during plugin loading + path := filepath.Join(plugin.PluginDir, mdFilepath(strings.ToUpper(name))) exists, err := fs.Exists(path) if err != nil { return nil, err } if !exists { - path = filepath.Join(plugin.PluginDir, fmt.Sprintf("%s.md", strings.ToLower(name))) + path = filepath.Join(plugin.PluginDir, mdFilepath(strings.ToLower(name))) } exists, err = fs.Exists(path) @@ -485,11 +498,15 @@ func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginId string, name } // nolint:gosec - // We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based - // on plugin the folder structure on disk and not user input. + // We can ignore the gosec G304 warning since we have cleaned the requested file path and subsequently + // use this with a prefix of the plugin's directory, which is set during plugin loading data, err := ioutil.ReadFile(path) if err != nil { return nil, err } return data, nil } + +func mdFilepath(mdFilename string) string { + return filepath.Clean(filepath.Join("/", fmt.Sprintf("%s.md", mdFilename))) +} diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 8b6689a9fbc1a..fb10aa0e4d347 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -23,9 +23,13 @@ func Test_GetPluginAssets(t *testing.T) { pluginDir := "." tmpFile, err := ioutil.TempFile(pluginDir, "") require.NoError(t, err) + tmpFileInParentDir, err := ioutil.TempFile("..", "") + require.NoError(t, err) t.Cleanup(func() { err := os.RemoveAll(tmpFile.Name()) assert.NoError(t, err) + err = os.RemoveAll(tmpFileInParentDir.Name()) + assert.NoError(t, err) }) expectedBody := "Plugin test" _, err = tmpFile.WriteString(expectedBody) @@ -61,6 +65,29 @@ func Test_GetPluginAssets(t *testing.T) { }) }) + t.Run("Given a request for a relative path", func(t *testing.T) { + p := plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, + PluginDir: pluginDir, + } + service := &fakePluginStore{ + plugins: map[string]plugins.PluginDTO{ + pluginID: p, + }, + } + l := &logger{} + + url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name()) + pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service, l, + func(sc *scenarioContext) { + callGetPluginAsset(sc) + + require.Equal(t, 404, sc.resp.Code) + }) + }) + t.Run("Given a request for an existing plugin file that is not listed as a signature covered file", func(t *testing.T) { p := plugins.PluginDTO{ JSONData: plugins.JSONData{ diff --git a/pkg/api/roles.go b/pkg/api/roles.go index f73b13bfb0b66..abda651a3bd21 100644 --- a/pkg/api/roles.go +++ b/pkg/api/roles.go @@ -137,29 +137,29 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_VIEWER)}, } - currentOrgReaderRole := accesscontrol.RoleRegistration{ + orgReaderRole := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Version: 4, - Name: "fixed:current.org:reader", - DisplayName: "Current Organization reader", - Description: "Read the current organization, such as its ID, name, address, or quotas.", + Version: 5, + Name: "fixed:organization:reader", + DisplayName: "Organization reader", + Description: "Read an organization, such as its ID, name, address, or quotas.", Group: "Organizations", Permissions: []accesscontrol.Permission{ {Action: ActionOrgsRead}, {Action: ActionOrgsQuotasRead}, }, }, - Grants: []string{string(models.ROLE_VIEWER)}, + Grants: []string{string(models.ROLE_VIEWER), accesscontrol.RoleGrafanaAdmin}, } - currentOrgWriterRole := accesscontrol.RoleRegistration{ + orgWriterRole := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Version: 4, - Name: "fixed:current.org:writer", - DisplayName: "Current Organization writer", - Description: "Read the current organization, its quotas, or its preferences. Update the current organization properties, or its preferences.", + Version: 5, + Name: "fixed:organization:writer", + DisplayName: "Organization writer", + Description: "Read an organization, its quotas, or its preferences. Update organization properties, or its preferences.", Group: "Organizations", - Permissions: accesscontrol.ConcatPermissions(currentOrgReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: accesscontrol.ConcatPermissions(orgReaderRole.Role.Permissions, []accesscontrol.Permission{ {Action: ActionOrgsPreferencesRead}, {Action: ActionOrgsWrite}, {Action: ActionOrgsPreferencesWrite}, @@ -168,27 +168,12 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - orgReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ - Version: 2, - Name: "fixed:orgs:reader", - DisplayName: "Organization reader", - Description: "Read the organization and its quotas.", - Group: "Organizations", - Permissions: []accesscontrol.Permission{ - {Action: ActionOrgsRead}, - {Action: ActionOrgsQuotasRead}, - }, - }, - Grants: []string{string(accesscontrol.RoleGrafanaAdmin)}, - } - - orgWriterRole := accesscontrol.RoleRegistration{ + orgMaintainerRole := accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Version: 4, - Name: "fixed:orgs:writer", - DisplayName: "Organization writer", - Description: "Create, read, write, or delete an organization. Read or write an organization's quotas.", + Version: 5, + Name: "fixed:organization:maintainer", + DisplayName: "Organization maintainer", + Description: "Create, read, write, or delete an organization. Read or write an organization's quotas. Needs to be assigned globally.", Group: "Organizations", Permissions: accesscontrol.ConcatPermissions(orgReaderRole.Role.Permissions, []accesscontrol.Permission{ {Action: ActionOrgsCreate}, @@ -202,7 +187,7 @@ func (hs *HTTPServer) declareFixedRoles() error { return hs.AccessControl.DeclareFixedRoles( provisioningWriterRole, datasourcesReaderRole, datasourcesWriterRole, datasourcesIdReaderRole, - datasourcesCompatibilityReaderRole, currentOrgReaderRole, currentOrgWriterRole, orgReaderRole, orgWriterRole, + datasourcesCompatibilityReaderRole, orgReaderRole, orgWriterRole, orgMaintainerRole, ) } @@ -231,3 +216,25 @@ var dataSourcesEditAccessEvaluator = accesscontrol.EvalAll( accesscontrol.EvalPermission(ActionDatasourcesRead, ScopeDatasourcesAll), accesscontrol.EvalPermission(ActionDatasourcesWrite), ) + +// orgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access +var orgPreferencesAccessEvaluator = accesscontrol.EvalAny( + accesscontrol.EvalAll( + accesscontrol.EvalPermission(ActionOrgsRead), + accesscontrol.EvalPermission(ActionOrgsWrite), + ), + accesscontrol.EvalAll( + accesscontrol.EvalPermission(ActionOrgsPreferencesRead), + accesscontrol.EvalPermission(ActionOrgsPreferencesWrite), + ), +) + +// orgsAccessEvaluator is used to protect the "Server Admin > Orgs" page access +// (you need to have read access to update or delete orgs; read is the minimum) +var orgsAccessEvaluator = accesscontrol.EvalPermission(ActionOrgsRead) + +// orgsCreateAccessEvaluator is used to protect the "Server Admin > Orgs > New Org" page access +var orgsCreateAccessEvaluator = accesscontrol.EvalAll( + accesscontrol.EvalPermission(ActionOrgsRead), + accesscontrol.EvalPermission(ActionOrgsCreate), +) diff --git a/pkg/api/system.go b/pkg/api/system.go new file mode 100644 index 0000000000000..e72f27075d302 --- /dev/null +++ b/pkg/api/system.go @@ -0,0 +1,20 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/util" + "os" +) + +func GetPerconaSaasHost() response.Response { + saasHost := "https://portal.percona.com" + envHost, ok := os.LookupEnv("PERCONA_PORTAL_URL") + + if ok { + saasHost = envHost + } + + return response.JSON(200, util.DynMap{ + "host": saasHost, + }) +} diff --git a/pkg/api/team.go b/pkg/api/team.go index 4eb4e18d10c61..29caf56dd213d 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -2,6 +2,8 @@ package api import ( "errors" + "net/http" + "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" @@ -10,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/teamguardian" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) // POST /api/teams @@ -95,16 +98,11 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { page = 1 } - var userIdFilter int64 - if hs.Cfg.EditorsCanAdmin && c.OrgRole != models.ROLE_ADMIN { - userIdFilter = c.SignedInUser.UserId - } - query := models.SearchTeamsQuery{ OrgId: c.OrgId, Query: c.Query("query"), Name: c.Query("name"), - UserIdFilter: userIdFilter, + UserIdFilter: userFilter(hs.Cfg.EditorsCanAdmin, c), Page: page, Limit: perPage, SignedInUser: c.SignedInUser, @@ -125,13 +123,32 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { return response.JSON(200, query.Result) } +// UserFilter returns the user ID used in a filter when querying a team +// 1. If the user is a viewer or editor, this will return the user's ID. +// 2. If EditorsCanAdmin is enabled and the user is an editor, this will return models.FilterIgnoreUser (0) +// 3. If the user is an admin, this will return models.FilterIgnoreUser (0) +func userFilter(editorsCanAdmin bool, c *models.ReqContext) int64 { + userIdFilter := c.SignedInUser.UserId + if (editorsCanAdmin && c.OrgRole == models.ROLE_EDITOR) || c.OrgRole == models.ROLE_ADMIN { + userIdFilter = models.FilterIgnoreUser + } + + return userIdFilter +} + // GET /api/teams/:teamId func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { + teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64) + if err != nil { + return response.Error(http.StatusBadRequest, "teamId is invalid", err) + } + query := models.GetTeamByIdQuery{ OrgId: c.OrgId, - Id: c.ParamsInt64(":teamId"), + Id: teamId, SignedInUser: c.SignedInUser, HiddenUsers: hs.Cfg.HiddenUsers, + UserIdFilter: userFilter(hs.Cfg.EditorsCanAdmin, c), } if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 01cd3e91e8ca6..5d5578a142a2d 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -16,6 +16,10 @@ import ( func (hs *HTTPServer) GetTeamMembers(c *models.ReqContext) response.Response { query := models.GetTeamMembersQuery{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId")} + if err := teamguardian.CanAdmin(hs.Bus, query.OrgId, query.TeamId, c.SignedInUser); err != nil { + return response.Error(403, "Not allowed to list team members", err) + } + if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { return response.Error(500, "Failed to get Team Members", err) } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 1b813d5866a57..dfb406f3ee18c 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -43,6 +43,14 @@ func (hs *HTTPServer) logoutUserFromAllDevicesInternal(ctx context.Context, user }) } +func (hs *HTTPServer) GetUserOAuthToken(c *models.ReqContext) response.Response { + if token := hs.OAuthTokenService.GetCurrentOAuthToken(hs.context, c.SignedInUser); token != nil { + return response.JSON(200, token) + } + + return response.Error(500, "Failed to get token", nil) +} + func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) response.Response { userQuery := models.GetUserByIdQuery{Id: userID} diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go index 9d07cf97f13a9..020eccfa8643a 100644 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go @@ -123,7 +123,7 @@ type alertingSecret struct{} func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm.Session) error { var results []struct { Id int - AlertmanagerConfiguration []byte + AlertmanagerConfiguration string } selectSQL := "SELECT id, alertmanager_configuration FROM alert_configuration" @@ -133,7 +133,7 @@ func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm for _, result := range results { result := result - postableUserConfig, err := notifier.Load(result.AlertmanagerConfiguration) + postableUserConfig, err := notifier.Load([]byte(result.AlertmanagerConfiguration)) if err != nil { return err } @@ -161,11 +161,12 @@ func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm } } - result.AlertmanagerConfiguration, err = json.Marshal(postableUserConfig) + marshalled, err := json.Marshal(postableUserConfig) if err != nil { return err } + result.AlertmanagerConfiguration = string(marshalled) if _, err := sess.Table("alert_configuration").Where("id = ?", result.Id).Update(&result); err != nil { return err } diff --git a/pkg/expr/service_test.go b/pkg/expr/service_test.go index 0380b2b9500cc..2c41fe44c37e7 100644 --- a/pkg/expr/service_test.go +++ b/pkg/expr/service_test.go @@ -13,10 +13,8 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/encryption/ossencryption" - "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/secrets/fakes" - "github.com/grafana/grafana/pkg/services/secrets/manager" + secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -31,16 +29,8 @@ func TestService(t *testing.T) { } cfg := setting.NewCfg() - encr := ossencryption.ProvideService() - settings := setting.ProvideProvider(cfg) - - secretsService, err := manager.ProvideSecretsService( - fakes.NewFakeSecretsStore(), - osskmsproviders.ProvideService(encr, settings), - encr, - settings, - ) - require.NoError(t, err) + + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) s := Service{ cfg: cfg, diff --git a/pkg/infra/serverlock/serverlock_integration_test.go b/pkg/infra/serverlock/serverlock_integration_test.go index e3f21187988aa..e80a8e87e9d73 100644 --- a/pkg/infra/serverlock/serverlock_integration_test.go +++ b/pkg/infra/serverlock/serverlock_integration_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestServerLok(t *testing.T) { @@ -16,21 +16,22 @@ func TestServerLok(t *testing.T) { counter := 0 fn := func(context.Context) { counter++ } - atInterval := time.Second * 1 + atInterval := time.Hour ctx := context.Background() //this time `fn` should be executed - assert.Nil(t, sl.LockAndExecute(ctx, "test-operation", atInterval, fn)) + require.Nil(t, sl.LockAndExecute(ctx, "test-operation", atInterval, fn)) + require.Equal(t, 1, counter) //this should not execute `fn` - assert.Nil(t, sl.LockAndExecute(ctx, "test-operation", atInterval, fn)) - assert.Nil(t, sl.LockAndExecute(ctx, "test-operation", atInterval, fn)) + require.Nil(t, sl.LockAndExecute(ctx, "test-operation", atInterval, fn)) + require.Nil(t, sl.LockAndExecute(ctx, "test-operation", atInterval, fn)) + require.Equal(t, 1, counter) - // wait 2 second. - <-time.After(time.Second * 2) + atInterval = time.Millisecond // now `fn` should be executed again err := sl.LockAndExecute(ctx, "test-operation", atInterval, fn) - assert.Nil(t, err) - assert.Equal(t, counter, 2) + require.Nil(t, err) + require.Equal(t, 2, counter) } diff --git a/pkg/infra/usagestats/service/usage_stats.go b/pkg/infra/usagestats/service/usage_stats.go index f72bdf8d80d71..b79ff5ce08a70 100644 --- a/pkg/infra/usagestats/service/usage_stats.go +++ b/pkg/infra/usagestats/service/usage_stats.go @@ -84,6 +84,7 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e metrics["stats.dashboards_viewers_can_admin.count"] = statsQuery.Result.DashboardsViewersCanAdmin metrics["stats.folders_viewers_can_edit.count"] = statsQuery.Result.FoldersViewersCanEdit metrics["stats.folders_viewers_can_admin.count"] = statsQuery.Result.FoldersViewersCanAdmin + metrics["stats.api_keys.count"] = statsQuery.Result.APIKeys ossEditionCount := 1 enterpriseEditionCount := 0 diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index 41b6718f0c5fe..7bef4b4375e2b 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -79,6 +79,7 @@ func TestMetrics(t *testing.T) { DashboardsViewersCanEdit: 2, FoldersViewersCanAdmin: 1, FoldersViewersCanEdit: 5, + APIKeys: 2, } getSystemStatsQuery = query return nil @@ -344,6 +345,7 @@ func TestMetrics(t *testing.T) { assert.Equal(t, getSystemStatsQuery.Result.FoldersViewersCanEdit, metrics.Get("stats.folders_viewers_can_edit.count").MustInt64()) assert.Equal(t, getSystemStatsQuery.Result.FoldersViewersCanAdmin, metrics.Get("stats.folders_viewers_can_admin.count").MustInt64()) assert.Equal(t, 15, metrics.Get("stats.total_auth_token.count").MustInt()) + assert.Equal(t, 2, metrics.Get("stats.api_keys.count").MustInt()) assert.Equal(t, 5, metrics.Get("stats.avg_auth_token_per_user.count").MustInt()) assert.Equal(t, 16, metrics.Get("stats.dashboard_versions.count").MustInt()) assert.Equal(t, 17, metrics.Get("stats.annotations.count").MustInt()) diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go index 0cf5e6d8c7af5..93398841e964a 100644 --- a/pkg/login/ldap_login.go +++ b/pkg/login/ldap_login.go @@ -57,9 +57,13 @@ var loginUsingLDAP = func(ctx context.Context, query *models.LoginUserQuery) (bo ReqContext: query.ReqContext, ExternalUser: externalUser, SignupAllowed: setting.LDAPAllowSignup, + UserLookupParams: models.UserLookupParams{ + Login: &externalUser.Login, + Email: &externalUser.Email, + UserID: nil, + }, } - err = bus.DispatchCtx(ctx, upsert) - if err != nil { + if err = bus.DispatchCtx(ctx, upsert); err != nil { return true, err } query.User = upsert.Result diff --git a/pkg/macaron/LICENSE.txt b/pkg/macaron/LICENSE.txt old mode 100644 new mode 100755 diff --git a/pkg/macaron/binding.go b/pkg/macaron/binding.go index 0ea03925b1930..fd37d2bf91e6b 100644 --- a/pkg/macaron/binding.go +++ b/pkg/macaron/binding.go @@ -2,8 +2,10 @@ package macaron import ( "encoding/json" + "errors" "fmt" "io" + "mime" "net/http" "reflect" ) @@ -11,9 +13,16 @@ import ( // Bind deserializes JSON payload from the request func Bind(req *http.Request, v interface{}) error { if req.Body != nil { - defer req.Body.Close() - err := json.NewDecoder(req.Body).Decode(v) - if err != nil && err != io.EOF { + m, _, err := mime.ParseMediaType(req.Header.Get("Content-type")) + if err != nil { + return err + } + if m != "application/json" { + return errors.New("bad content type") + } + defer func() { _ = req.Body.Close() }() + err = json.NewDecoder(req.Body).Decode(v) + if err != nil && !errors.Is(err, io.EOF) { return err } } @@ -29,7 +38,7 @@ func validate(obj interface{}) error { if validator, ok := obj.(Validator); ok { return validator.Validate() } - // Otherwise, use relfection to match `binding:"Required"` struct field tags. + // Otherwise, use reflection to match `binding:"Required"` struct field tags. // Resolve all pointers and interfaces, until we get a concrete type. t := reflect.TypeOf(obj) v := reflect.ValueOf(obj) @@ -69,6 +78,7 @@ func validate(obj interface{}) error { return err } } + default: // ignore } return nil } diff --git a/pkg/macaron/context.go b/pkg/macaron/context.go index 309c882f53cee..1275bc0742dcf 100644 --- a/pkg/macaron/context.go +++ b/pkg/macaron/context.go @@ -77,13 +77,17 @@ func (ctx *Context) run() { // RemoteAddr returns more real IP address. func (ctx *Context) RemoteAddr() string { addr := ctx.Req.Header.Get("X-Real-IP") + + if len(addr) == 0 { + // X-Forwarded-For may contain multiple IP addresses, separated by + // commas. + addr = strings.TrimSpace(strings.Split(ctx.Req.Header.Get("X-Forwarded-For"), ",")[0]) + } + if len(addr) == 0 { - addr = ctx.Req.Header.Get("X-Forwarded-For") - if addr == "" { - addr = ctx.Req.RemoteAddr - if i := strings.LastIndex(addr, ":"); i > -1 { - addr = addr[:i] - } + addr = ctx.Req.RemoteAddr + if i := strings.LastIndex(addr, ":"); i > -1 { + addr = addr[:i] } } return addr diff --git a/pkg/macaron/go.mod b/pkg/macaron/go.mod index 2356bfa820859..4b2c8deef4928 100644 --- a/pkg/macaron/go.mod +++ b/pkg/macaron/go.mod @@ -1,3 +1,11 @@ module gopkg.in/macaron.v1 go 1.17 + +require github.com/stretchr/testify v1.7.0 + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/pkg/macaron/go.sum b/pkg/macaron/go.sum new file mode 100644 index 0000000000000..acb88a48f6840 --- /dev/null +++ b/pkg/macaron/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/macaron/response_writer.go b/pkg/macaron/response_writer.go index 4853eed4492c6..f345237b9e6e1 100644 --- a/pkg/macaron/response_writer.go +++ b/pkg/macaron/response_writer.go @@ -56,6 +56,14 @@ type responseWriter struct { func (rw *responseWriter) WriteHeader(s int) { rw.callBefore() + + // Avoid panic if status code is not a valid HTTP status code + if s < 100 || s > 999 { + rw.ResponseWriter.WriteHeader(500) + rw.status = 500 + return + } + rw.ResponseWriter.WriteHeader(s) rw.status = s } diff --git a/pkg/macaron/response_writer_test.go b/pkg/macaron/response_writer_test.go new file mode 100644 index 0000000000000..170293a0835a3 --- /dev/null +++ b/pkg/macaron/response_writer_test.go @@ -0,0 +1,43 @@ +package macaron_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/macaron.v1" +) + +func Test_responseWriter_WriteHeader(t *testing.T) { + t.Run("it should set status code as expected", func(t *testing.T) { + f := fakeResponseWriter{} + rw := macaron.NewResponseWriter("GET", &f) + rw.WriteHeader(200) + require.Equal(t, 200, rw.Status()) + require.Equal(t, 200, f.Status) + }) + + t.Run("it should set status code to 500 if WriteHeader is called with invalid HTTP status", func(t *testing.T) { + f := fakeResponseWriter{} + rw := macaron.NewResponseWriter("GET", &f) + rw.WriteHeader(0) + require.Equal(t, 500, rw.Status()) + require.Equal(t, 500, f.Status) + }) +} + +type fakeResponseWriter struct { + Status int +} + +func (f *fakeResponseWriter) Header() http.Header { + return http.Header{} +} + +func (f *fakeResponseWriter) Write([]byte) (int, error) { + return 0, nil +} + +func (f *fakeResponseWriter) WriteHeader(statusCode int) { + f.Status = statusCode +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 08e60936e5f26..f151d09d7d229 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -128,21 +128,31 @@ func Auth(options *AuthOptions) web.Handler { } } -// AdminOrFeatureEnabled creates a middleware that allows access -// if the signed in user is either an Org Admin or if the -// feature flag is enabled. +// AdminOrEditorAndFeatureEnabled creates a middleware that allows +// access if the signed in user is either an Org Admin or if they +// are an Org Editor and the feature flag is enabled. // Intended for when feature flags open up access to APIs that // are otherwise only available to admins. -func AdminOrFeatureEnabled(enabled bool) web.Handler { +func AdminOrEditorAndFeatureEnabled(enabled bool) web.Handler { return func(c *models.ReqContext) { if c.OrgRole == models.ROLE_ADMIN { return } - if !enabled { - accessForbidden(c) + if c.OrgRole == models.ROLE_EDITOR && enabled { + return } + + accessForbidden(c) + } +} + +func PmmAdmin(c *models.ReqContext) { + if c.OrgRole == models.ROLE_ADMIN || c.IsGrafanaAdmin { + return } + + accessForbidden(c) } // SnapshotPublicModeOrSignedIn creates a middleware that allows access diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go new file mode 100644 index 0000000000000..7bce53f5666ae --- /dev/null +++ b/pkg/middleware/csrf.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "errors" + "net/http" + "net/url" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/util" +) + +func CSRF(loginCookieName string, logger log.Logger) func(http.Handler) http.Handler { + // As per RFC 7231/4.2.2 these methods are idempotent: + // (GET is excluded because it may have side effects in some APIs) + safeMethods := []string{"HEAD", "OPTIONS", "TRACE"} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If request has no login cookie - skip CSRF checks + if _, err := r.Cookie(loginCookieName); errors.Is(err, http.ErrNoCookie) { + next.ServeHTTP(w, r) + return + } + // Skip CSRF checks for "safe" methods + for _, method := range safeMethods { + if r.Method == method { + next.ServeHTTP(w, r) + return + } + } + // Otherwise - verify that Origin matches the server origin + netAddr, err := util.SplitHostPortDefault(r.Host, "", "0") // we ignore the port + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + origin, err := url.Parse(r.Header.Get("Origin")) + if err != nil { + logger.Error("error parsing Origin header", "err", err) + } + if err != nil || netAddr.Host == "" || (origin.String() != "" && origin.Hostname() != netAddr.Host) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/pkg/middleware/csrf_test.go b/pkg/middleware/csrf_test.go new file mode 100644 index 0000000000000..351810fe7b905 --- /dev/null +++ b/pkg/middleware/csrf_test.go @@ -0,0 +1,124 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +func TestMiddlewareCSRF(t *testing.T) { + tests := []struct { + name string + cookieName string + method string + origin string + host string + code int + }{ + { + name: "mismatched origin and host is forbidden", + cookieName: "foo", + method: "GET", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusForbidden, + }, + { + name: "mismatched origin and host is NOT forbidden with a 'Safe Method'", + cookieName: "foo", + method: "TRACE", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusOK, + }, + { + name: "mismatched origin and host is NOT forbidden without a cookie", + cookieName: "", + method: "GET", + origin: "http://notLocalhost", + host: "localhost", + code: http.StatusOK, + }, + { + name: "malformed host is a bad request", + cookieName: "foo", + method: "GET", + host: "localhost:80:80", + code: http.StatusBadRequest, + }, + { + name: "host works without port", + cookieName: "foo", + method: "GET", + host: "localhost", + origin: "http://localhost", + code: http.StatusOK, + }, + { + name: "port does not have to match", + cookieName: "foo", + method: "GET", + host: "localhost:80", + origin: "http://localhost:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host works with port", + cookieName: "foo", + method: "GET", + host: "[::1]:3000", + origin: "http://[::1]:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host (with longer address) works with port", + cookieName: "foo", + method: "GET", + host: "[2001:db8::1]:3000", + origin: "http://[2001:db8::1]:3000", + code: http.StatusOK, + }, + { + name: "IPv6 host (with longer address) works without port", + cookieName: "foo", + method: "GET", + host: "[2001:db8::1]", + origin: "http://[2001:db8::1]", + code: http.StatusOK, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := csrfScenario(t, tt.cookieName, tt.method, tt.origin, tt.host) + require.Equal(t, tt.code, rr.Code) + }) + } +} + +func csrfScenario(t *testing.T, cookieName, method, origin, host string) *httptest.ResponseRecorder { + req, err := http.NewRequest(method, "/", nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&http.Cookie{ + Name: cookieName, + }) + + // Note: Not sure where host header populates req.Host, or how that works. + req.Host = host + req.Header.Set("HOST", host) + + req.Header.Set("ORIGIN", origin) + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + }) + + rr := httptest.NewRecorder() + handler := CSRF(cookieName, log.New("test"))(testHandler) + handler.ServeHTTP(rr, req) + return rr +} diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index ab0cd0d768e58..f58579c28b0ea 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -219,7 +219,8 @@ func TestMiddlewareQuota(t *testing.T) { }, func(cfg *setting.Cfg) { configure(cfg) - cfg.UnifiedAlerting.Enabled = true + cfg.UnifiedAlerting.Enabled = new(bool) + *cfg.UnifiedAlerting.Enabled = true cfg.Quota.Org.AlertRule = quotaUsed }) @@ -233,7 +234,8 @@ func TestMiddlewareQuota(t *testing.T) { }, func(cfg *setting.Cfg) { configure(cfg) - cfg.UnifiedAlerting.Enabled = true + cfg.UnifiedAlerting.Enabled = new(bool) + *cfg.UnifiedAlerting.Enabled = true cfg.Quota.Org.AlertRule = quotaUsed + 1 }) diff --git a/pkg/models/apikey.go b/pkg/models/apikey.go index 9c05105d10d0c..dc417cf213ffd 100644 --- a/pkg/models/apikey.go +++ b/pkg/models/apikey.go @@ -72,3 +72,11 @@ type ApiKeyDTO struct { Role RoleType `json:"role"` Expiration *time.Time `json:"expiration,omitempty"` } + +type ApiKeyDetailsDTO struct { + Id int64 `json:"id"` + OrgId int64 `json:"orgId,omitempty"` + Name string `json:"name"` + Role RoleType `json:"role"` + Expiration *time.Time `json:"expiration,omitempty"` +} diff --git a/pkg/models/stats.go b/pkg/models/stats.go index d042d02753bb1..cd992d7f870bb 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -18,6 +18,7 @@ type SystemStats struct { Folders int64 ProvisionedDashboards int64 AuthTokens int64 + APIKeys int64 `xorm:"api_keys"` DashboardVersions int64 Annotations int64 AlertRules int64 diff --git a/pkg/models/team.go b/pkg/models/team.go index 328e1815b90b5..8fe1ac47fd673 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -55,8 +55,12 @@ type GetTeamByIdQuery struct { SignedInUser *SignedInUser HiddenUsers map[string]struct{} Result *TeamDTO + UserIdFilter int64 } +// FilterIgnoreUser is used in a get / search teams query when the caller does not want to filter teams by user ID / membership +const FilterIgnoreUser int64 = 0 + type GetTeamsByUserQuery struct { OrgId int64 UserId int64 `json:"userId"` diff --git a/pkg/models/user_auth.go b/pkg/models/user_auth.go index 6a96ccb6bad2d..2f670031b360f 100644 --- a/pkg/models/user_auth.go +++ b/pkg/models/user_auth.go @@ -54,11 +54,11 @@ type RequestURIKey struct{} // COMMANDS type UpsertUserCommand struct { - ReqContext *ReqContext - ExternalUser *ExternalUserInfo + ReqContext *ReqContext + ExternalUser *ExternalUserInfo + UserLookupParams + Result *User SignupAllowed bool - - Result *User } type SetAuthInfoCommand struct { @@ -95,9 +95,14 @@ type LoginUserQuery struct { type GetUserByAuthInfoQuery struct { AuthModule string AuthId string - UserId int64 - Email string - Login string + UserLookupParams +} + +type UserLookupParams struct { + // Describes lookup order as well + UserID *int64 // if set, will try to find the user by id + Email *string // if set, will try to find the user by email + Login *string // if set, will try to find the user by login } type GetExternalUserInfoByLoginQuery struct { diff --git a/pkg/plugins/manager/dashboard_import.go b/pkg/plugins/manager/dashboard_import.go index dac03746be2b4..28600b731c6ff 100644 --- a/pkg/plugins/manager/dashboard_import.go +++ b/pkg/plugins/manager/dashboard_import.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/plugins" ) @@ -47,6 +48,13 @@ func (e *DashTemplateEvaluator) Eval() (*simplejson.Json, error) { inputType := inputDefJson.Get("type").MustString() input := e.findInput(inputName, inputType) + // force expressions value to `__expr__` + if inputDefJson.Get("pluginId").MustString() == expr.DatasourceType { + input = &plugins.ImportDashboardInput{ + Value: expr.DatasourceType, + } + } + if input == nil { return nil, &DashboardInputMissingError{VariableName: inputName} } diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 9afc6fc748cd1..016ab44b94425 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -79,6 +79,7 @@ func TestLoader_Load(t *testing.T) { Metrics: true, Alerting: true, Logs: true, + Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, Module: "app/plugins/datasource/cloudwatch/module", diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index 991b7474362c1..c2fb041da87ea 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -392,6 +392,8 @@ func flushStream(plugin backendplugin.Plugin, stream callResourceClientResponseS w.Header().Add(k, v) } } + + proxyutil.SetProxyResponseHeaders(w.Header()) w.WriteHeader(resp.Status) } diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index f996bc45a9f63..6a515c54af642 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -447,7 +447,8 @@ func TestPluginManager_lifecycle_managed(t *testing.T) { ctx.pluginClient.CallResourceHandlerFunc = func(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { return sender.Send(&backend.CallResourceResponse{ - Status: http.StatusOK, + Status: http.StatusOK, + Headers: map[string][]string{}, }) } @@ -456,7 +457,13 @@ func TestPluginManager_lifecycle_managed(t *testing.T) { w := httptest.NewRecorder() err = ctx.manager.callResourceInternal(w, req, backend.PluginContext{PluginID: testPluginID}) require.NoError(t, err) + for { + if w.Flushed { + break + } + } require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy")) }) }) }) diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 1f83afd121165..26679d9e58377 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/rendering" + secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -45,7 +46,7 @@ func ProvideBackgroundServiceRegistry( rendering *rendering.RenderingService, tokenService models.UserTokenBackgroundService, provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, pm *manager.PluginManager, metrics *metrics.InternalMetricsService, usageStats *uss.UsageStats, updateChecker *updatechecker.Service, - tracing *tracing.TracingService, remoteCache *remotecache.RemoteCache, + tracing *tracing.TracingService, remoteCache *remotecache.RemoteCache, secretsService *secretsManager.SecretsService, // Need to make sure these are initialized, is there a better place to put them? _ *azuremonitor.Service, _ *cloudwatch.CloudWatchService, _ *elasticsearch.Service, _ *graphite.Service, _ *influxdb.Service, _ *loki.Service, _ *opentsdb.Service, _ *prometheus.Service, _ *tempo.Service, @@ -69,7 +70,8 @@ func ProvideBackgroundServiceRegistry( metrics, usageStats, tracing, - remoteCache) + remoteCache, + secretsService) } // BackgroundServiceRegistry provides background services. diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index c0c0575891c16..ded3de730038c 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -42,6 +42,27 @@ type ResourceStore interface { GetResourcesPermissions(ctx context.Context, orgID int64, query GetResourcesPermissionsQuery) ([]ResourcePermission, error) } +// HasGlobalAccess checks user access with globally assigned permissions only +func HasGlobalAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { + return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { + if ac.IsDisabled() { + return fallback(c) + } + + userCopy := *c.SignedInUser + userCopy.OrgId = GlobalOrgID + userCopy.OrgRole = "" + userCopy.OrgName = "" + hasAccess, err := ac.Evaluate(c.Req.Context(), &userCopy, evaluator) + if err != nil { + c.Logger.Error("Error from access control system", "error", err) + return false + } + + return hasAccess + } +} + func HasAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { if ac.IsDisabled() { diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 2e4a0cb1b86f9..b505adbc5cc6e 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -64,6 +64,7 @@ type RoleDTO struct { Description string `json:"description"` Group string `xorm:"group_name" json:"group"` Permissions []Permission `json:"permissions,omitempty"` + Delegatable *bool `json:"delegatable,omitempty"` ID int64 `json:"-" xorm:"pk autoincr 'id'"` OrgID int64 `json:"-" xorm:"org_id"` diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go index 582e0fa0e4c23..98aa1273684d7 100644 --- a/pkg/services/alerting/engine.go +++ b/pkg/services/alerting/engine.go @@ -7,6 +7,11 @@ import ( "time" "github.com/benbjohnson/clock" + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/ext" + tlog "github.com/opentracing/opentracing-go/log" + "golang.org/x/sync/errgroup" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -15,10 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" - "github.com/opentracing/opentracing-go" - "github.com/opentracing/opentracing-go/ext" - tlog "github.com/opentracing/opentracing-go/log" - "golang.org/x/sync/errgroup" ) // AlertEngine is the background process that @@ -41,9 +42,9 @@ type AlertEngine struct { usageStatsService usagestats.Service } -// IsDisabled returns true if the alerting service is disable for this instance. +// IsDisabled returns true if the alerting service is disabled for this instance. func (e *AlertEngine) IsDisabled() bool { - return !setting.AlertingEnabled || !setting.ExecuteAlerts || e.Cfg.UnifiedAlerting.Enabled + return setting.AlertingEnabled == nil || !*setting.AlertingEnabled || !setting.ExecuteAlerts || e.Cfg.UnifiedAlerting.IsEnabled() } // ProvideAlertEngine returns a new AlertEngine. diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index b9861e5f770de..a8e6bf2e7bb17 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -58,7 +58,7 @@ func (handler *defaultResultHandler) handle(evalContext *EvalContext) error { EvalData: annotationData, } - if err := bus.Dispatch(cmd); err != nil { + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { if errors.Is(err, models.ErrCannotChangeStateOnPausedAlert) { handler.log.Error("Cannot change state on alert that's paused", "error", err) return err diff --git a/pkg/services/alerting/service.go b/pkg/services/alerting/service.go index f691d07a6388d..44cd632fc823b 100644 --- a/pkg/services/alerting/service.go +++ b/pkg/services/alerting/service.go @@ -2,6 +2,7 @@ package alerting import ( "context" + "fmt" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" @@ -74,6 +75,7 @@ func (s *AlertNotificationService) UpdateAlertNotification(ctx context.Context, model := models.AlertNotification{ Id: cmd.Id, + OrgId: cmd.OrgId, Name: cmd.Name, Type: cmd.Type, Settings: cmd.Settings, @@ -134,7 +136,11 @@ func (s *AlertNotificationService) createNotifier(ctx context.Context, model *mo return nil, err } - if query.Result != nil && query.Result.SecureSettings != nil { + if query.Result == nil { + return nil, fmt.Errorf("unable to find the alert notification") + } + + if query.Result.SecureSettings != nil { var err error secureSettingsMap, err = s.EncryptionService.DecryptJsonData(ctx, query.Result.SecureSettings, setting.SecretKey) if err != nil { diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index 01bc3cb087c53..c4a45f3107546 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -33,6 +33,7 @@ func (s *AlertNotificationService) HandleNotificationTestCommand(ctx context.Con model := models.AlertNotification{ Id: cmd.ID, + OrgId: cmd.OrgID, Name: cmd.Name, Type: cmd.Type, Settings: cmd.Settings, diff --git a/pkg/services/auth/jwt/auth_test.go b/pkg/services/auth/jwt/auth_test.go index 71c315465c1af..de6e5351341e9 100644 --- a/pkg/services/auth/jwt/auth_test.go +++ b/pkg/services/auth/jwt/auth_test.go @@ -148,7 +148,7 @@ func TestCachingJWKHTTPResponse(t *testing.T) { assert.Equal(t, 1, *sc.reqCount) }) - jwkCachingScenario(t, "respects TTL setting", func(t *testing.T, sc cachingScenarioContext) { + jwkCachingScenario(t, "respects TTL setting (while cached)", func(t *testing.T, sc cachingScenarioContext) { var err error token0 := sign(t, &jwKeys[0], jwt.Claims{Subject: subject}) @@ -160,17 +160,9 @@ func TestCachingJWKHTTPResponse(t *testing.T) { require.Error(t, err) assert.Equal(t, 1, *sc.reqCount) - - time.Sleep(sc.cfg.JWTAuthCacheTTL + time.Millisecond) - - _, err = sc.authJWTSvc.Verify(sc.ctx, token1) - require.NoError(t, err) - _, err = sc.authJWTSvc.Verify(sc.ctx, token0) - require.Error(t, err) - - assert.Equal(t, 2, *sc.reqCount) }, func(t *testing.T, cfg *setting.Cfg) { - cfg.JWTAuthCacheTTL = time.Second + // Arbitrary high value, several times what the test should take. + cfg.JWTAuthCacheTTL = time.Minute }) jwkCachingScenario(t, "does not cache the response when TTL is zero", func(t *testing.T, sc cachingScenarioContext) { diff --git a/pkg/services/contexthandler/authproxy/authproxy.go b/pkg/services/contexthandler/authproxy/authproxy.go index adc99ce7eb643..f6642fce037c9 100644 --- a/pkg/services/contexthandler/authproxy/authproxy.go +++ b/pkg/services/contexthandler/authproxy/authproxy.go @@ -247,6 +247,11 @@ func (auth *AuthProxy) LoginViaLDAP() (int64, error) { ReqContext: auth.ctx, SignupAllowed: auth.cfg.LDAPAllowSignup, ExternalUser: extUser, + UserLookupParams: models.UserLookupParams{ + Login: &extUser.Login, + Email: &extUser.Email, + UserID: nil, + }, } if err := bus.Dispatch(upsert); err != nil { return 0, err @@ -303,6 +308,11 @@ func (auth *AuthProxy) LoginViaHeader() (int64, error) { ReqContext: auth.ctx, SignupAllowed: auth.cfg.AuthProxyAutoSignUp, ExternalUser: extUser, + UserLookupParams: models.UserLookupParams{ + UserID: nil, + Login: &extUser.Login, + Email: &extUser.Email, + }, } err := bus.Dispatch(upsert) diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 53487f0ce8405..98ccfdf485e0d 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -692,9 +692,15 @@ func (l *LibraryElementService) deleteLibraryElementsInFolderUID(c context.Conte if err != nil { return err } + + if len(folderUIDs) == 0 { + return models.ErrFolderNotFound + } + if len(folderUIDs) != 1 { return fmt.Errorf("found %d folders, while expecting at most one", len(folderUIDs)) } + folderID := folderUIDs[0].ID if err := l.requirePermissionsOnFolder(c, signedInUser, folderID); err != nil { diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 682ee206f30e7..65ecc357664a7 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -65,6 +65,12 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) { require.EqualError(t, err, ErrFolderHasConnectedLibraryElements.Error()) }) + scenarioWithPanel(t, "When an admin tries to delete a folder uid that doesn't exist, it should fail", + func(t *testing.T, sc scenarioContext) { + err := sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.Uid+"xxxx") + require.EqualError(t, err, models.ErrFolderNotFound.Error()) + }) + scenarioWithPanel(t, "When an admin tries to delete a folder that contains disconnected elements, it should delete all disconnected elements too", func(t *testing.T, sc scenarioContext) { command := getCreateVariableCommand(sc.folder.Id, "query0") diff --git a/pkg/services/live/pushws/push_pipeline.go b/pkg/services/live/pushws/push_pipeline.go index 5651a5d4227bc..4d6b6f38160b4 100644 --- a/pkg/services/live/pushws/push_pipeline.go +++ b/pkg/services/live/pushws/push_pipeline.go @@ -55,11 +55,13 @@ func (s *PipelinePushHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) if err != nil { return } + defer func() { _ = conn.Close() }() setupWSConn(r.Context(), conn, s.config) for { _, body, err := conn.ReadMessage() if err != nil { + logger.Debug("Error reading websocket connection", "error", err) break } diff --git a/pkg/services/live/pushws/push_stream.go b/pkg/services/live/pushws/push_stream.go index a7892df2b9772..0b1ffbdabbb31 100644 --- a/pkg/services/live/pushws/push_stream.go +++ b/pkg/services/live/pushws/push_stream.go @@ -57,11 +57,13 @@ func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { if err != nil { return } + defer func() { _ = conn.Close() }() setupWSConn(r.Context(), conn, s.config) for { _, body, err := conn.ReadMessage() if err != nil { + logger.Debug("Error reading websocket connection", "error", err) break } diff --git a/pkg/services/login/authinfoservice/database.go b/pkg/services/login/authinfoservice/database.go index 24ad631d5217c..75431745a7f85 100644 --- a/pkg/services/login/authinfoservice/database.go +++ b/pkg/services/login/authinfoservice/database.go @@ -38,6 +38,10 @@ func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query * } func (s *Implementation) GetAuthInfo(query *models.GetAuthInfoQuery) error { + if query.UserId == 0 && query.AuthId == "" { + return models.ErrUserNotFound + } + userAuth := &models.UserAuth{ UserId: query.UserId, AuthModule: query.AuthModule, diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index 096dd7968324d..ac64fc2ea3365 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -85,11 +85,12 @@ func (s *Implementation) LookupAndFix(query *models.GetUserByAuthInfoQuery) (boo } // if user id was specified and doesn't match the user_auth entry, remove it - if query.UserId != 0 && query.UserId != authQuery.Result.UserId { - err := s.DeleteAuthInfo(&models.DeleteAuthInfoCommand{ + if query.UserLookupParams.UserID != nil && + *query.UserLookupParams.UserID != 0 && + *query.UserLookupParams.UserID != authQuery.Result.UserId { + if err := s.DeleteAuthInfo(&models.DeleteAuthInfoCommand{ UserAuth: authQuery.Result, - }) - if err != nil { + }); err != nil { s.logger.Error("Error removing user_auth entry", "error", err) } @@ -120,42 +121,42 @@ func (s *Implementation) LookupAndFix(query *models.GetUserByAuthInfoQuery) (boo return false, nil, nil, models.ErrUserNotFound } -func (s *Implementation) LookupByOneOf(userId int64, email string, login string) (bool, *models.User, error) { - foundUser := false +func (s *Implementation) LookupByOneOf(params *models.UserLookupParams) (*models.User, error) { var user *models.User var err error + foundUser := false // If not found, try to find the user by id - if userId != 0 { - foundUser, user, err = s.getUserById(userId) + if params.UserID != nil && *params.UserID != 0 { + foundUser, user, err = s.getUserById(*params.UserID) if err != nil { - return false, nil, err + return nil, err } } // If not found, try to find the user by email address - if !foundUser && email != "" { - user = &models.User{Email: email} + if !foundUser && params.Email != nil && *params.Email != "" { + user = &models.User{Email: *params.Email} foundUser, err = s.getUser(user) if err != nil { - return false, nil, err + return nil, err } } // If not found, try to find the user by login - if !foundUser && login != "" { - user = &models.User{Login: login} + if !foundUser && params.Login != nil && *params.Login != "" { + user = &models.User{Login: *params.Login} foundUser, err = s.getUser(user) if err != nil { - return false, nil, err + return nil, err } } if !foundUser { - return false, nil, models.ErrUserNotFound + return nil, models.ErrUserNotFound } - return foundUser, user, nil + return user, nil } func (s *Implementation) GenericOAuthLookup(authModule string, authId string, userID int64) (*models.UserAuth, error) { @@ -184,7 +185,7 @@ func (s *Implementation) LookupAndUpdate(query *models.GetUserByAuthInfoQuery) ( // 2. FindByUserDetails if !foundUser { - _, user, err = s.LookupByOneOf(query.UserId, query.Email, query.Login) + user, err = s.LookupByOneOf(&query.UserLookupParams) if err != nil { return nil, err } diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go index bba765b3fb90b..880a964a2198c 100644 --- a/pkg/services/login/authinfoservice/user_auth_test.go +++ b/pkg/services/login/authinfoservice/user_auth_test.go @@ -39,7 +39,7 @@ func TestUserAuth(t *testing.T) { // By Login login := "loginuser0" - query := &models.GetUserByAuthInfoQuery{Login: login} + query := &models.GetUserByAuthInfoQuery{UserLookupParams: models.UserLookupParams{Login: &login}} user, err := srv.LookupAndUpdate(query) require.Nil(t, err) @@ -48,7 +48,9 @@ func TestUserAuth(t *testing.T) { // By ID id := user.Id - _, user, err = srv.LookupByOneOf(id, "", "") + user, err = srv.LookupByOneOf(&models.UserLookupParams{ + UserID: &id, + }) require.Nil(t, err) require.Equal(t, user.Id, id) @@ -56,7 +58,9 @@ func TestUserAuth(t *testing.T) { // By Email email := "user1@test.com" - _, user, err = srv.LookupByOneOf(0, email, "") + user, err = srv.LookupByOneOf(&models.UserLookupParams{ + Email: &email, + }) require.Nil(t, err) require.Equal(t, user.Email, email) @@ -64,7 +68,9 @@ func TestUserAuth(t *testing.T) { // Don't find nonexistent user email = "nonexistent@test.com" - _, user, err = srv.LookupByOneOf(0, email, "") + user, err = srv.LookupByOneOf(&models.UserLookupParams{ + Email: &email, + }) require.Equal(t, models.ErrUserNotFound, err) require.Nil(t, user) @@ -81,7 +87,7 @@ func TestUserAuth(t *testing.T) { // create user_auth entry login := "loginuser0" - query.Login = login + query.UserLookupParams.Login = &login user, err = srv.LookupAndUpdate(query) require.Nil(t, err) @@ -95,9 +101,9 @@ func TestUserAuth(t *testing.T) { require.Equal(t, user.Login, login) // get with non-matching id - id := user.Id + idPlusOne := user.Id + 1 - query.UserId = id + 1 + query.UserLookupParams.UserID = &idPlusOne user, err = srv.LookupAndUpdate(query) require.Nil(t, err) @@ -138,7 +144,9 @@ func TestUserAuth(t *testing.T) { login := "loginuser0" // Calling GetUserByAuthInfoQuery on an existing user will populate an entry in the user_auth table - query := &models.GetUserByAuthInfoQuery{Login: login, AuthModule: "test", AuthId: "test"} + query := &models.GetUserByAuthInfoQuery{AuthModule: "test", AuthId: "test", UserLookupParams: models.UserLookupParams{ + Login: &login, + }} user, err := srv.LookupAndUpdate(query) require.Nil(t, err) @@ -186,7 +194,9 @@ func TestUserAuth(t *testing.T) { // Calling srv.LookupAndUpdateQuery on an existing user will populate an entry in the user_auth table // Make the first log-in during the past getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } - query := &models.GetUserByAuthInfoQuery{Login: login, AuthModule: "test1", AuthId: "test1"} + query := &models.GetUserByAuthInfoQuery{AuthModule: "test1", AuthId: "test1", UserLookupParams: models.UserLookupParams{ + Login: &login, + }} user, err := srv.LookupAndUpdate(query) getTime = time.Now @@ -196,7 +206,9 @@ func TestUserAuth(t *testing.T) { // Add a second auth module for this user // Have this module's last log-in be more recent getTime = func() time.Time { return time.Now().AddDate(0, 0, -1) } - query = &models.GetUserByAuthInfoQuery{Login: login, AuthModule: "test2", AuthId: "test2"} + query = &models.GetUserByAuthInfoQuery{AuthModule: "test2", AuthId: "test2", UserLookupParams: models.UserLookupParams{ + Login: &login, + }} user, err = srv.LookupAndUpdate(query) getTime = time.Now @@ -236,16 +248,21 @@ func TestUserAuth(t *testing.T) { // Expect to pass since there's a matching login user getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } - query := &models.GetUserByAuthInfoQuery{Login: login, AuthModule: genericOAuthModule, AuthId: ""} + query := &models.GetUserByAuthInfoQuery{AuthModule: genericOAuthModule, AuthId: "", UserLookupParams: models.UserLookupParams{ + Login: &login, + }} user, err := srv.LookupAndUpdate(query) getTime = time.Now require.Nil(t, err) require.Equal(t, user.Login, login) + otherLoginUser := "aloginuser" // Should throw a "user not found" error since there's no matching login user getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) } - query = &models.GetUserByAuthInfoQuery{Login: "aloginuser", AuthModule: genericOAuthModule, AuthId: ""} + query = &models.GetUserByAuthInfoQuery{AuthModule: genericOAuthModule, AuthId: "", UserLookupParams: models.UserLookupParams{ + Login: &otherLoginUser, + }} user, err = srv.LookupAndUpdate(query) getTime = time.Now diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 4b05a81d6e743..41aecd1e3092f 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -45,11 +45,9 @@ func (ls *Implementation) UpsertUser(cmd *models.UpsertUserCommand) error { extUser := cmd.ExternalUser user, err := ls.AuthInfoService.LookupAndUpdate(&models.GetUserByAuthInfoQuery{ - AuthModule: extUser.AuthModule, - AuthId: extUser.AuthId, - UserId: extUser.UserId, - Email: extUser.Email, - Login: extUser.Login, + AuthModule: extUser.AuthModule, + AuthId: extUser.AuthId, + UserLookupParams: cmd.UserLookupParams, }) if err != nil { if !errors.Is(err, models.ErrUserNotFound) { diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index 573aebe1cbacc..bde2f74461f0b 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -94,10 +94,12 @@ func Test_teamSync(t *testing.T) { AuthInfoService: authInfoMock, } - upserCmd := &models.UpsertUserCommand{ExternalUser: &models.ExternalUserInfo{Email: "test_user@example.org"}} + email := "test_user@example.org" + upserCmd := &models.UpsertUserCommand{ExternalUser: &models.ExternalUserInfo{Email: email}, + UserLookupParams: models.UserLookupParams{Email: &email}} expectedUser := &models.User{ Id: 1, - Email: "test_user@example.org", + Email: email, Name: "test_user", Login: "test_user", } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index af6dc8d00e668..b701f74a3bb0f 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -587,10 +587,11 @@ func (c *GettableApiAlertingConfig) validate() error { // Config is the top-level configuration for Alertmanager's config files. type Config struct { - Global *config.GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"` - Route *Route `yaml:"route,omitempty" json:"route,omitempty"` - InhibitRules []*config.InhibitRule `yaml:"inhibit_rules,omitempty" json:"inhibit_rules,omitempty"` - Templates []string `yaml:"templates" json:"templates"` + Global *config.GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"` + Route *Route `yaml:"route,omitempty" json:"route,omitempty"` + InhibitRules []*config.InhibitRule `yaml:"inhibit_rules,omitempty" json:"inhibit_rules,omitempty"` + MuteTimeIntervals []config.MuteTimeInterval `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` + Templates []string `yaml:"templates" json:"templates"` } // A Route is a node that contains definitions of how to handle alerts. This is modified @@ -745,6 +746,9 @@ func (c *Config) UnmarshalJSON(b []byte) error { if len(c.Route.Match) > 0 || len(c.Route.MatchRE) > 0 { return fmt.Errorf("root route must not have any matchers") } + if len(c.Route.MuteTimeIntervals) > 0 { + return fmt.Errorf("root route must not have any mute time intervals") + } for _, r := range c.InhibitRules { if err := r.UnmarshalYAML(noopUnmarshal); err != nil { @@ -752,6 +756,33 @@ func (c *Config) UnmarshalJSON(b []byte) error { } } + tiNames := make(map[string]struct{}) + for _, mt := range c.MuteTimeIntervals { + if mt.Name == "" { + return fmt.Errorf("missing name in mute time interval") + } + if _, ok := tiNames[mt.Name]; ok { + return fmt.Errorf("mute time interval %q is not unique", mt.Name) + } + tiNames[mt.Name] = struct{}{} + } + return checkTimeInterval(c.Route, tiNames) +} + +func checkTimeInterval(r *Route, timeIntervals map[string]struct{}) error { + for _, sr := range r.Routes { + if err := checkTimeInterval(sr, timeIntervals); err != nil { + return err + } + } + if len(r.MuteTimeIntervals) == 0 { + return nil + } + for _, mt := range r.MuteTimeIntervals { + if _, ok := timeIntervals[mt]; !ok { + return fmt.Errorf("undefined time interval %q used in route", mt) + } + } return nil } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go index d6be362547654..a5c88d0162148 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go @@ -2,6 +2,7 @@ package definitions import ( "encoding/json" + "errors" "io/ioutil" "strings" "testing" @@ -405,6 +406,283 @@ email_configs: } } +func Test_ConfigUnmashaling(t *testing.T) { + for _, tc := range []struct { + desc, input string + err error + }{ + { + desc: "empty mute time name should error", + err: errors.New("missing name in mute time interval"), + input: ` + { + "route": { + "receiver": "grafana-default-email" + }, + "mute_time_intervals": [ + { + "name": "", + "time_intervals": [ + { + "times": [ + { + "start_time": "00:00", + "end_time": "12:00" + } + ] + } + ] + } + ], + "templates": null, + "receivers": [ + { + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [ + { + "uid": "uxwfZvtnz", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "" + }, + "secureFields": {} + } + ] + } + ] + } + `, + }, + { + desc: "not unique mute time names should error", + err: errors.New("mute time interval \"test1\" is not unique"), + input: ` + { + "route": { + "receiver": "grafana-default-email" + }, + "mute_time_intervals": [ + { + "name": "test1", + "time_intervals": [ + { + "times": [ + { + "start_time": "00:00", + "end_time": "12:00" + } + ] + } + ] + }, + { + "name": "test1", + "time_intervals": [ + { + "times": [ + { + "start_time": "00:00", + "end_time": "12:00" + } + ] + } + ] + } + ], + "templates": null, + "receivers": [ + { + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [ + { + "uid": "uxwfZvtnz", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "" + }, + "secureFields": {} + } + ] + } + ] + } + `, + }, + { + desc: "mute time intervals on root route should error", + err: errors.New("root route must not have any mute time intervals"), + input: ` + { + "route": { + "receiver": "grafana-default-email", + "mute_time_intervals": ["test1"] + }, + "mute_time_intervals": [ + { + "name": "test1", + "time_intervals": [ + { + "times": [ + { + "start_time": "00:00", + "end_time": "12:00" + } + ] + } + ] + } + ], + "templates": null, + "receivers": [ + { + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [ + { + "uid": "uxwfZvtnz", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "" + }, + "secureFields": {} + } + ] + } + ] + } + `, + }, + { + desc: "undefined mute time names in routes should error", + err: errors.New("undefined time interval \"test2\" used in route"), + input: ` + { + "route": { + "receiver": "grafana-default-email", + "routes": [ + { + "receiver": "grafana-default-email", + "object_matchers": [ + [ + "a", + "=", + "b" + ] + ], + "mute_time_intervals": [ + "test2" + ] + } + ] + }, + "mute_time_intervals": [ + { + "name": "test1", + "time_intervals": [ + { + "times": [ + { + "start_time": "00:00", + "end_time": "12:00" + } + ] + } + ] + } + ], + "templates": null, + "receivers": [ + { + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [ + { + "uid": "uxwfZvtnz", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "" + }, + "secureFields": {} + } + ] + } + ] + } + `, + }, + { + desc: "valid config should not error", + input: ` + { + "route": { + "receiver": "grafana-default-email", + "routes": [ + { + "receiver": "grafana-default-email", + "object_matchers": [ + [ + "a", + "=", + "b" + ] + ], + "mute_time_intervals": [ + "test1" + ] + } + ] + }, + "mute_time_intervals": [ + { + "name": "test1", + "time_intervals": [ + { + "times": [ + { + "start_time": "00:00", + "end_time": "12:00" + } + ] + } + ] + } + ], + "templates": null, + "receivers": [ + { + "name": "grafana-default-email", + "grafana_managed_receiver_configs": [ + { + "uid": "uxwfZvtnz", + "name": "email receiver", + "type": "email", + "disableResolveMessage": false, + "settings": { + "addresses": "" + }, + "secureFields": {} + } + ] + } + ] + } + `, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + var out Config + err := json.Unmarshal([]byte(tc.input), &out) + require.Equal(t, tc.err, err) + }) + } +} + func Test_GettableUserConfigUnmarshaling(t *testing.T) { for _, tc := range []struct { desc, input string diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.json b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.json index cc68eb4935988..9413f6b652470 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.json +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.json @@ -3,6 +3,19 @@ "foo": "bar" }, "alertmanager_config": { + "mute_time_intervals": [ + { + "name": "foo", + "time_intervals": [ + { + "years": [ + "2020:2022", + "2030" + ] + } + ] + } + ], "receivers": [ { "email_configs": [ diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.yaml b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.yaml index 0ecbfd8a7e41d..b618b74cba018 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.yaml +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test_artifact.yaml @@ -1,6 +1,12 @@ template_files: foo: bar alertmanager_config: | + mute_time_intervals: + - name: foo + time_intervals: + - years: + - 2020:2022 + - "2030" receivers: - email_configs: - auth_password: shh diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index f3e9e35c7f247..6eab8554d53a0 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -116,7 +116,10 @@ func (p *AlertingProxy) withReq( var m map[string]interface{} if err := json.Unmarshal(resp.Body(), &m); err == nil { if message, ok := m["message"]; ok { - errMessage = message.(string) + errMessageStr, isString := message.(string) + if isString { + errMessage = errMessageStr + } } } } else if strings.HasPrefix(resp.Header().Get("Content-Type"), "text/html") { @@ -255,7 +258,7 @@ func ErrResp(status int, err error, msg string, args ...interface{}) *response.N if msg != "" { err = errors.WithMessagef(err, msg, args...) } - return response.Error(status, err.Error(), nil) + return response.Error(status, "API error", err) } // accessForbiddenResp creates a response of forbidden access. diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index 9a38d8e84669e..4b02732e2825b 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -8,6 +8,7 @@ import ( "runtime/debug" "sort" "strconv" + "strings" "time" "github.com/grafana/grafana/pkg/expr/classic" @@ -273,6 +274,48 @@ func executeQueriesAndExpressions(ctx AlertExecCtx, data []models.AlertQuery, no return exprService.TransformData(ctx.Ctx, queryDataReq) } +// datasourceUIDsToRefIDs returns a sorted slice of Ref IDs for each Datasource UID. +// +// If refIDsToDatasourceUIDs is nil then this function also returns nil. Likewise, +// if it is an empty map then it too returns an empty map. +// +// For example, given the following: +// +// map[string]string{ +// "ref1": "datasource1", +// "ref2": "datasource1", +// "ref3": "datasource2", +// } +// +// we would expect: +// +// map[string][]string{ +// "datasource1": []string{"ref1", "ref2"}, +// "datasource2": []string{"ref3"}, +// } +func datasourceUIDsToRefIDs(refIDsToDatasourceUIDs map[string]string) map[string][]string { + if refIDsToDatasourceUIDs == nil { + return nil + } + + // The ref IDs must be sorted. However, instead of sorting them once + // for each Datasource UID we can append them all to a slice and then + // sort them once + refIDs := make([]string, 0, len(refIDsToDatasourceUIDs)) + for refID := range refIDsToDatasourceUIDs { + refIDs = append(refIDs, refID) + } + sort.Strings(refIDs) + + result := make(map[string][]string) + for _, refID := range refIDs { + datasourceUID := refIDsToDatasourceUIDs[refID] + result[datasourceUID] = append(result[datasourceUID], refID) + } + + return result +} + // evaluateExecutionResult takes the ExecutionResult which includes data.Frames returned // from SSE (Server Side Expressions). It will create Results (slice of Result) with a State // extracted from each Frame. @@ -317,12 +360,12 @@ func evaluateExecutionResult(execResults ExecutionResults, ts time.Time) Results } if len(execResults.NoData) > 0 { - m := make(map[string]struct{}) - for _, datasourceUID := range execResults.NoData { - if _, ok := m[datasourceUID]; !ok { - appendNoData(data.Labels{"datasource_uid": datasourceUID}) - m[datasourceUID] = struct{}{} - } + noData := datasourceUIDsToRefIDs(execResults.NoData) + for datasourceUID, refIDs := range noData { + appendNoData(data.Labels{ + "datasource_uid": datasourceUID, + "ref_id": strings.Join(refIDs, ","), + }) } return evalResults } diff --git a/pkg/services/ngalert/eval/eval_test.go b/pkg/services/ngalert/eval/eval_test.go index d470a9f03eb2d..0f6a1f6d3c295 100644 --- a/pkg/services/ngalert/eval/eval_test.go +++ b/pkg/services/ngalert/eval/eval_test.go @@ -304,11 +304,11 @@ func TestEvaluateExecutionResultsNoData(t *testing.T) { } v := evaluateExecutionResult(results, time.Time{}) require.Len(t, v, 1) - require.Equal(t, data.Labels{"datasource_uid": "1"}, v[0].Instance) + require.Equal(t, data.Labels{"datasource_uid": "1", "ref_id": "A"}, v[0].Instance) require.Equal(t, NoData, v[0].State) }) - t.Run("no data for Ref IDs will produce NoData result for each data source", func(t *testing.T) { + t.Run("no data for Ref IDs will produce NoData result for each Ref ID", func(t *testing.T) { results := ExecutionResults{ NoData: map[string]string{ "A": "1", @@ -318,16 +318,25 @@ func TestEvaluateExecutionResultsNoData(t *testing.T) { } v := evaluateExecutionResult(results, time.Time{}) require.Len(t, v, 2) - require.Equal(t, NoData, v[0].State) - require.Equal(t, NoData, v[1].State) datasourceUIDs := make([]string, 0, len(v)) + refIDs := make([]string, 0, len(v)) + for _, next := range v { + require.Equal(t, NoData, next.State) + datasourceUID, ok := next.Instance["datasource_uid"] require.True(t, ok) require.NotEqual(t, "", datasourceUID) datasourceUIDs = append(datasourceUIDs, datasourceUID) + + refID, ok := next.Instance["ref_id"] + require.True(t, ok) + require.NotEqual(t, "", refID) + refIDs = append(refIDs, refID) } + require.ElementsMatch(t, []string{"1", "2"}, datasourceUIDs) + require.ElementsMatch(t, []string{"A,B", "C"}, refIDs) }) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index e8bfc6a8442e4..07c1e13dea11b 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -41,6 +41,7 @@ func (executionErrorState ExecutionErrorState) String() string { const ( AlertingErrState ExecutionErrorState = "Alerting" + ErrorErrState ExecutionErrorState = "Error" ) const ( diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 01afcb6cd5299..5d7977ec01ea0 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -6,6 +6,8 @@ import ( "time" "github.com/benbjohnson/clock" + "golang.org/x/sync/errgroup" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -23,7 +25,6 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "golang.org/x/sync/errgroup" ) const ( @@ -185,7 +186,7 @@ func (ng *AlertNG) IsDisabled() bool { if ng.Cfg == nil { return true } - return !ng.Cfg.UnifiedAlerting.Enabled + return !ng.Cfg.UnifiedAlerting.IsEnabled() } // getRuleDefaultIntervalSeconds returns the default rule interval if the interval is not set. diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index 0eee6ef65e84b..027f976f9b788 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -18,6 +18,7 @@ import ( gokit_log "github.com/go-kit/kit/log" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cluster" + "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/dispatch" "github.com/prometheus/alertmanager/inhibit" "github.com/prometheus/alertmanager/nflog" @@ -26,6 +27,7 @@ import ( "github.com/prometheus/alertmanager/provider/mem" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/types" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" @@ -110,6 +112,10 @@ type Alertmanager struct { silencer *silence.Silencer silences *silence.Silences + // muteTimes is a map where the key is the name of the mute_time_interval + // and the value represents all configured time_interval(s) + muteTimes map[string][]timeinterval.TimeInterval + stageMetrics *notify.Metrics dispatcherMetrics *dispatch.DispatcherMetrics @@ -329,6 +335,14 @@ func (am *Alertmanager) templateFromPaths(paths ...string) (*template.Template, return tmpl, nil } +func (am *Alertmanager) buildMuteTimesMap(muteTimeIntervals []config.MuteTimeInterval) map[string][]timeinterval.TimeInterval { + muteTimes := make(map[string][]timeinterval.TimeInterval, len(muteTimeIntervals)) + for _, ti := range muteTimeIntervals { + muteTimes[ti.Name] = ti.TimeIntervals + } + return muteTimes +} + // applyConfig applies a new configuration by re-initializing all components using the configuration provided. // It is not safe to call concurrently. func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig []byte) (err error) { @@ -375,6 +389,7 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig if err != nil { return fmt.Errorf("failed to build integration map: %w", err) } + // Now, let's put together our notification pipeline routingStage := make(notify.RoutingStage, len(integrationsMap)) @@ -386,14 +401,16 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig } am.inhibitor = inhibit.NewInhibitor(am.alerts, cfg.AlertmanagerConfig.InhibitRules, am.marker, am.gokitLogger) + am.muteTimes = am.buildMuteTimesMap(cfg.AlertmanagerConfig.MuteTimeIntervals) am.silencer = silence.NewSilencer(am.silences, am.marker, am.gokitLogger) meshStage := notify.NewGossipSettleStage(am.peer) inhibitionStage := notify.NewMuteStage(am.inhibitor) + timeMuteStage := notify.NewTimeMuteStage(am.muteTimes) silencingStage := notify.NewMuteStage(am.silencer) for name := range integrationsMap { stage := am.createReceiverStage(name, integrationsMap[name], am.waitFunc, am.notificationLog) - routingStage[name] = notify.MultiStage{meshStage, silencingStage, inhibitionStage, stage} + routingStage[name] = notify.MultiStage{meshStage, silencingStage, timeMuteStage, inhibitionStage, stage} } am.route = dispatch.NewRoute(cfg.AlertmanagerConfig.Route.AsAMRoute(), nil) diff --git a/pkg/services/ngalert/notifier/available_channels.go b/pkg/services/ngalert/notifier/available_channels.go index dbbf8328dd55c..74cfe4ac7f197 100644 --- a/pkg/services/ngalert/notifier/available_channels.go +++ b/pkg/services/ngalert/notifier/available_channels.go @@ -716,6 +716,12 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { PropertyName: "url", Required: true, }, + { + Label: "Message", + Element: alerting.ElementTypeTextArea, + Placeholder: `{{ template "default.message" . }}`, + PropertyName: "message", + }, }, }, { diff --git a/pkg/services/ngalert/notifier/channels/alertmanager.go b/pkg/services/ngalert/notifier/channels/alertmanager.go index 99485420e14f7..6f9abc0d24233 100644 --- a/pkg/services/ngalert/notifier/channels/alertmanager.go +++ b/pkg/services/ngalert/notifier/channels/alertmanager.go @@ -22,7 +22,9 @@ func NewAlertmanagerNotifier(model *NotificationChannelConfig, _ *template.Templ if model.Settings == nil { return nil, receiverInitError{Reason: "no settings supplied"} } - + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } urlStr := model.Settings.Get("url").MustString() if urlStr == "" { return nil, receiverInitError{Reason: "could not find url property in settings", Cfg: *model} diff --git a/pkg/services/ngalert/notifier/channels/alertmanager_test.go b/pkg/services/ngalert/notifier/channels/alertmanager_test.go index 35790d4f22458..b548edf4b5965 100644 --- a/pkg/services/ngalert/notifier/channels/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/channels/alertmanager_test.go @@ -49,11 +49,13 @@ func TestNewAlertmanagerNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: c.receiverName, - Type: "alertmanager", - Settings: settingsJSON, + Name: c.receiverName, + Type: "alertmanager", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -130,11 +132,13 @@ func TestAlertmanagerNotifier_Notify(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: c.receiverName, - Type: "alertmanager", - Settings: settingsJSON, + Name: c.receiverName, + Type: "alertmanager", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/default_template.go b/pkg/services/ngalert/notifier/channels/default_template.go index cac318b896ec4..a9f28345a1139 100644 --- a/pkg/services/ngalert/notifier/channels/default_template.go +++ b/pkg/services/ngalert/notifier/channels/default_template.go @@ -9,10 +9,13 @@ import ( "github.com/stretchr/testify/require" ) +const DefaultMessageTitleEmbed = `{{ template "default.title" . }}` + var DefaultTemplateString = ` {{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }} {{ define "__text_alert_list" }}{{ range . }} +Value: {{ or .ValueString "[no value]" }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }}Annotations: @@ -33,6 +36,7 @@ Labels: {{ define "__teams_text_alert_list" }}{{ range . }} +Value: {{ or .ValueString "[no value]" }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} @@ -65,6 +69,7 @@ const TemplateForTestsString = ` {{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }} {{ define "__text_alert_list" }}{{ range . }} +Value: {{ or .ValueString "[no value]" }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }}Annotations: diff --git a/pkg/services/ngalert/notifier/channels/default_template_test.go b/pkg/services/ngalert/notifier/channels/default_template_test.go index 1400cb280df00..4e61ac5901594 100644 --- a/pkg/services/ngalert/notifier/channels/default_template_test.go +++ b/pkg/services/ngalert/notifier/channels/default_template_test.go @@ -21,7 +21,7 @@ func TestDefaultTemplateString(t *testing.T) { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, Annotations: model.LabelSet{ - "ann1": "annv1", "__dashboardUid__": "dbuid123", "__panelId__": "puid123", + "ann1": "annv1", "__dashboardUid__": "dbuid123", "__panelId__": "puid123", "__value_string__": "1234", }, StartsAt: time.Now(), EndsAt: time.Now().Add(1 * time.Hour), @@ -30,7 +30,7 @@ func TestDefaultTemplateString(t *testing.T) { }, { // Firing without dashboard and panel ID. Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__value_string__": "1234"}, StartsAt: time.Now(), EndsAt: time.Now().Add(2 * time.Hour), GeneratorURL: "http://localhost/alert2", @@ -39,7 +39,7 @@ func TestDefaultTemplateString(t *testing.T) { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val3"}, Annotations: model.LabelSet{ - "ann1": "annv3", "__dashboardUid__": "dbuid456", "__panelId__": "puid456", + "ann1": "annv3", "__dashboardUid__": "dbuid456", "__panelId__": "puid456", "__value_string__": "1234", }, StartsAt: time.Now().Add(-1 * time.Hour), EndsAt: time.Now().Add(-30 * time.Minute), @@ -48,7 +48,7 @@ func TestDefaultTemplateString(t *testing.T) { }, { // Resolved without dashboard and panel ID. Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val4"}, - Annotations: model.LabelSet{"ann1": "annv4"}, + Annotations: model.LabelSet{"ann1": "annv4", "__value_string__": "1234"}, StartsAt: time.Now().Add(-2 * time.Hour), EndsAt: time.Now().Add(-3 * time.Hour), GeneratorURL: "http://localhost/alert4", @@ -85,13 +85,14 @@ func TestDefaultTemplateString(t *testing.T) { expected string }{ { - templateString: `{{ template "default.title" .}}`, + templateString: DefaultMessageTitleEmbed, expected: `[FIRING:2] (alert1)`, }, { templateString: `{{ template "default.message" .}}`, expected: `**Firing** +Value: 1234 Labels: - alertname = alert1 - lbl1 = val1 @@ -102,6 +103,7 @@ Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matc Dashboard: http://localhost/grafana/d/dbuid123 Panel: http://localhost/grafana/d/dbuid123?viewPanel=puid123 +Value: 1234 Labels: - alertname = alert1 - lbl1 = val2 @@ -113,6 +115,7 @@ Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matc **Resolved** +Value: 1234 Labels: - alertname = alert1 - lbl1 = val3 @@ -123,6 +126,7 @@ Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matc Dashboard: http://localhost/grafana/d/dbuid456 Panel: http://localhost/grafana/d/dbuid456?viewPanel=puid456 +Value: 1234 Labels: - alertname = alert1 - lbl1 = val4 @@ -136,6 +140,7 @@ Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matc templateString: `{{ template "teams.default.message" .}}`, expected: `**Firing** +Value: 1234 Labels: - alertname = alert1 - lbl1 = val1 @@ -153,6 +158,7 @@ Panel: http://localhost/grafana/d/dbuid123?viewPanel=puid123 +Value: 1234 Labels: - alertname = alert1 - lbl1 = val2 @@ -169,6 +175,7 @@ Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matc **Resolved** +Value: 1234 Labels: - alertname = alert1 - lbl1 = val3 @@ -186,6 +193,7 @@ Panel: http://localhost/grafana/d/dbuid456?viewPanel=puid456 +Value: 1234 Labels: - alertname = alert1 - lbl1 = val4 diff --git a/pkg/services/ngalert/notifier/channels/dingding.go b/pkg/services/ngalert/notifier/channels/dingding.go index e6ac8d5f89576..22d21af346723 100644 --- a/pkg/services/ngalert/notifier/channels/dingding.go +++ b/pkg/services/ngalert/notifier/channels/dingding.go @@ -19,7 +19,7 @@ const defaultDingdingMsgType = "link" // NewDingDingNotifier is the constructor for the Dingding notifier func NewDingDingNotifier(model *NotificationChannelConfig, t *template.Template) (*DingDingNotifier, error) { if model.Settings == nil { - return nil, receiverInitError{Reason: "no settings supplied", Cfg: *model} + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } url := model.Settings.Get("url").MustString() @@ -74,7 +74,7 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo tmpl, _ := TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr) message := tmpl(dd.Message) - title := tmpl(`{{ template "default.title" . }}`) + title := tmpl(DefaultMessageTitleEmbed) var bodyMsg map[string]interface{} if tmpl(dd.MsgType) == "actionCard" { @@ -102,7 +102,7 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo u := tmpl(dd.URL) if tmplErr != nil { - dd.log.Debug("failed to template DingDing message", "err", tmplErr.Error()) + dd.log.Warn("failed to template DingDing message", "err", tmplErr.Error()) } body, err := json.Marshal(bodyMsg) diff --git a/pkg/services/ngalert/notifier/channels/dingding_test.go b/pkg/services/ngalert/notifier/channels/dingding_test.go index fb2ef530b5b3c..5af18ae7d6f71 100644 --- a/pkg/services/ngalert/notifier/channels/dingding_test.go +++ b/pkg/services/ngalert/notifier/channels/dingding_test.go @@ -38,7 +38,7 @@ func TestDingdingNotifier(t *testing.T) { { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__value_string__": "1234"}, }, }, }, @@ -46,7 +46,7 @@ func TestDingdingNotifier(t *testing.T) { "msgtype": "link", "link": map[string]interface{}{ "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", - "text": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: 1234\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "title": "[FIRING:1] (val1)", }, }, diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go index 5842445180d1f..83900e964e7eb 100644 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ b/pkg/services/ngalert/notifier/channels/discord.go @@ -28,7 +28,7 @@ type DiscordNotifier struct { func NewDiscordNotifier(model *NotificationChannelConfig, t *template.Template) (*DiscordNotifier, error) { if model.Settings == nil { - return nil, receiverInitError{Reason: "no settings supplied", Cfg: *model} + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } avatarURL := model.Settings.Get("avatar_url").MustString() @@ -86,7 +86,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, } embed := simplejson.New() - embed.Set("title", tmpl(`{{ template "default.title" . }}`)) + embed.Set("title", tmpl(DefaultMessageTitleEmbed)) embed.Set("footer", footer) embed.Set("type", "rich") @@ -100,7 +100,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, u := tmpl(d.WebhookURL) if tmplErr != nil { - d.log.Debug("failed to template Discord message", "err", tmplErr.Error()) + d.log.Warn("failed to template Discord message", "err", tmplErr.Error()) } body, err := json.Marshal(bodyJSON) diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index 9fc1b947a926d..b14be65971392 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -43,7 +43,7 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ @@ -103,7 +103,7 @@ func TestDiscordNotifier(t *testing.T) { { name: "Default config with one alert, use default discord username", settings: `{ - "url": "http://localhost", + "url": "http://localhost", "use_discord_username": true }`, alerts: []*types.Alert{ @@ -115,7 +115,7 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go index 574f3d2ff918a..4839a860876a7 100644 --- a/pkg/services/ngalert/notifier/channels/email.go +++ b/pkg/services/ngalert/notifier/channels/email.go @@ -29,7 +29,7 @@ type EmailNotifier struct { // for the EmailNotifier. func NewEmailNotifier(model *NotificationChannelConfig, t *template.Template) (*EmailNotifier, error) { if model.Settings == nil { - return nil, receiverInitError{Reason: "no settings supplied", Cfg: *model} + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } addressesString := model.Settings.Get("addresses").MustString() @@ -63,7 +63,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, var tmplErr error tmpl, data := TmplText(ctx, en.tmpl, as, en.log, &tmplErr) - title := tmpl(`{{ template "default.title" . }}`) + title := tmpl(DefaultMessageTitleEmbed) alertPageURL := en.tmpl.ExternalURL.String() ruleURL := en.tmpl.ExternalURL.String() @@ -100,7 +100,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, } if tmplErr != nil { - en.log.Debug("failed to template email message", "err", tmplErr.Error()) + en.log.Warn("failed to template email message", "err", tmplErr.Error()) } if err := bus.DispatchCtx(ctx, cmd); err != nil { diff --git a/pkg/services/ngalert/notifier/channels/googlechat.go b/pkg/services/ngalert/notifier/channels/googlechat.go index 307026fb8cfee..4da0ec4b0f76b 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat.go +++ b/pkg/services/ngalert/notifier/channels/googlechat.go @@ -19,17 +19,24 @@ import ( // alert notifications to Google chat. type GoogleChatNotifier struct { *Base - URL string - log log.Logger - tmpl *template.Template + URL string + log log.Logger + tmpl *template.Template + content string } func NewGoogleChatNotifier(model *NotificationChannelConfig, t *template.Template) (*GoogleChatNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} + } + url := model.Settings.Get("url").MustString() if url == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find url property in settings"} } + content := model.Settings.Get("message").MustString(`{{ template "default.message" . }}`) + return &GoogleChatNotifier{ Base: NewBase(&models.AlertNotification{ Uid: model.UID, @@ -38,9 +45,10 @@ func NewGoogleChatNotifier(model *NotificationChannelConfig, t *template.Templat DisableResolveMessage: model.DisableResolveMessage, Settings: model.Settings, }), - URL: url, - log: log.New("alerting.notifier.googlechat"), - tmpl: t, + URL: url, + log: log.New("alerting.notifier.googlechat"), + tmpl: t, + content: content, }, nil } @@ -53,7 +61,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( widgets := []widget{} - if msg := tmpl(`{{ template "default.message" . }}`); msg != "" { + if msg := tmpl(gcn.content); msg != "" { // Add a text paragraph widget for the message if there is a message. // Google Chat API doesn't accept an empty text property. widgets = append(widgets, textParagraphWidget{ @@ -63,6 +71,11 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( }) } + if tmplErr != nil { + gcn.log.Warn("failed to template Google Chat message", "err", tmplErr.Error()) + tmplErr = nil + } + ruleURL := joinUrlPath(gcn.tmpl.ExternalURL.String(), "/alerting/list", gcn.log) // Add a button widget (link to Grafana). widgets = append(widgets, buttonWidget{ @@ -89,12 +102,12 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( // Nest the required structs. res := &outerStruct{ - PreviewText: tmpl(`{{ template "default.title" . }}`), - FallbackText: tmpl(`{{ template "default.title" . }}`), + PreviewText: tmpl(DefaultMessageTitleEmbed), + FallbackText: tmpl(DefaultMessageTitleEmbed), Cards: []card{ { Header: header{ - Title: tmpl(`{{ template "default.title" . }}`), + Title: tmpl(DefaultMessageTitleEmbed), }, Sections: []section{ { @@ -107,7 +120,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( u := tmpl(gcn.URL) if tmplErr != nil { - gcn.log.Debug("failed to template GoogleChat message", "err", tmplErr.Error()) + gcn.log.Warn("failed to template GoogleChat message", "err", tmplErr.Error()) } body, err := json.Marshal(res) diff --git a/pkg/services/ngalert/notifier/channels/googlechat_test.go b/pkg/services/ngalert/notifier/channels/googlechat_test.go index 9d57958e84f61..49298ebc64b92 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat_test.go +++ b/pkg/services/ngalert/notifier/channels/googlechat_test.go @@ -60,7 +60,7 @@ func TestGoogleChatNotifier(t *testing.T) { Widgets: []widget{ textParagraphWidget{ Text: text{ - Text: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, }, buttonWidget{ @@ -119,7 +119,7 @@ func TestGoogleChatNotifier(t *testing.T) { Widgets: []widget{ textParagraphWidget{ Text: text{ - Text: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", }, }, buttonWidget{ @@ -152,6 +152,114 @@ func TestGoogleChatNotifier(t *testing.T) { name: "Error in initing", settings: `{}`, expInitError: `failed to validate receiver "googlechat_testing" of type "googlechat": could not find url property in settings`, + }, { + name: "Customized message", + settings: `{"url": "http://localhost", "message": "I'm a custom template and you have {{ len .Alerts.Firing }} firing alert."}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &outerStruct{ + PreviewText: "[FIRING:1] (val1)", + FallbackText: "[FIRING:1] (val1)", + Cards: []card{ + { + Header: header{ + Title: "[FIRING:1] (val1)", + }, + Sections: []section{ + { + Widgets: []widget{ + textParagraphWidget{ + Text: text{ + Text: "I'm a custom template and you have 1 firing alert.", + }, + }, + buttonWidget{ + Buttons: []button{ + { + TextButton: textButton{ + Text: "OPEN IN GRAFANA", + OnClick: onClick{ + OpenLink: openLink{ + URL: "http://localhost/alerting/list", + }, + }, + }, + }, + }, + }, + textParagraphWidget{ + Text: text{ + // RFC822 only has the minute, hence it works in most cases. + Text: "Grafana v" + setting.BuildVersion + " | " + constNow.Format(time.RFC822), + }, + }, + }, + }, + }, + }, + }, + }, + expMsgError: nil, + }, { + name: "Invalid template", + settings: `{"url": "http://localhost", "message": "I'm a custom template {{ .NotAField }} bad template"}`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &outerStruct{ + PreviewText: "[FIRING:1] (val1)", + FallbackText: "[FIRING:1] (val1)", + Cards: []card{ + { + Header: header{ + Title: "[FIRING:1] (val1)", + }, + Sections: []section{ + { + Widgets: []widget{ + textParagraphWidget{ + Text: text{ + Text: "I'm a custom template ", + }, + }, + buttonWidget{ + Buttons: []button{ + { + TextButton: textButton{ + Text: "OPEN IN GRAFANA", + OnClick: onClick{ + OpenLink: openLink{ + URL: "http://localhost/alerting/list", + }, + }, + }, + }, + }, + }, + textParagraphWidget{ + Text: text{ + // RFC822 only has the minute, hence it works in most cases. + Text: "Grafana v" + setting.BuildVersion + " | " + constNow.Format(time.RFC822), + }, + }, + }, + }, + }, + }, + }, + }, + expMsgError: nil, }, } diff --git a/pkg/services/ngalert/notifier/channels/kafka.go b/pkg/services/ngalert/notifier/channels/kafka.go index bcfd9d052c006..15a54b8fe6fb6 100644 --- a/pkg/services/ngalert/notifier/channels/kafka.go +++ b/pkg/services/ngalert/notifier/channels/kafka.go @@ -27,6 +27,10 @@ type KafkaNotifier struct { // NewKafkaNotifier is the constructor function for the Kafka notifier. func NewKafkaNotifier(model *NotificationChannelConfig, t *template.Template) (*KafkaNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} + } + endpoint := model.Settings.Get("kafkaRestProxy").MustString() if endpoint == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find kafka rest proxy endpoint property in settings"} @@ -68,7 +72,7 @@ func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, bodyJSON := simplejson.New() bodyJSON.Set("alert_state", state) - bodyJSON.Set("description", tmpl(`{{ template "default.title" . }}`)) + bodyJSON.Set("description", tmpl(DefaultMessageTitleEmbed)) bodyJSON.Set("client", "Grafana") bodyJSON.Set("details", tmpl(`{{ template "default.message" . }}`)) @@ -95,7 +99,7 @@ func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, topicURL := strings.TrimRight(kn.Endpoint, "/") + "/topics/" + tmpl(kn.Topic) if tmplErr != nil { - kn.log.Debug("failed to template Kafka message", "err", tmplErr.Error()) + kn.log.Warn("failed to template Kafka message", "err", tmplErr.Error()) } cmd := &models.SendWebhookSync{ diff --git a/pkg/services/ngalert/notifier/channels/kafka_test.go b/pkg/services/ngalert/notifier/channels/kafka_test.go index c5071edb559c9..2032da298f383 100644 --- a/pkg/services/ngalert/notifier/channels/kafka_test.go +++ b/pkg/services/ngalert/notifier/channels/kafka_test.go @@ -53,7 +53,7 @@ func TestKafkaNotifier(t *testing.T) { "client": "Grafana", "client_url": "http://localhost/alerting/list", "description": "[FIRING:1] (val1)", - "details": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" } } @@ -88,7 +88,7 @@ func TestKafkaNotifier(t *testing.T) { "client": "Grafana", "client_url": "http://localhost/alerting/list", "description": "[FIRING:2] ", - "details": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", "incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" } } diff --git a/pkg/services/ngalert/notifier/channels/line.go b/pkg/services/ngalert/notifier/channels/line.go index 9cebd032c6046..c18c55960e9df 100644 --- a/pkg/services/ngalert/notifier/channels/line.go +++ b/pkg/services/ngalert/notifier/channels/line.go @@ -19,6 +19,13 @@ var ( // NewLineNotifier is the constructor for the LINE notifier func NewLineNotifier(model *NotificationChannelConfig, t *template.Template, fn GetDecryptedValueFn) (*LineNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} + } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } + token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString()) if token == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find token in settings"} @@ -58,12 +65,12 @@ func (ln *LineNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e body := fmt.Sprintf( "%s\n%s\n\n%s", - tmpl(`{{ template "default.title" . }}`), + tmpl(DefaultMessageTitleEmbed), ruleURL, tmpl(`{{ template "default.message" . }}`), ) if tmplErr != nil { - ln.log.Debug("failed to template Line message", "err", tmplErr.Error()) + ln.log.Warn("failed to template Line message", "err", tmplErr.Error()) } form := url.Values{} diff --git a/pkg/services/ngalert/notifier/channels/line_test.go b/pkg/services/ngalert/notifier/channels/line_test.go index a32b215005e04..5a4e96fdecde9 100644 --- a/pkg/services/ngalert/notifier/channels/line_test.go +++ b/pkg/services/ngalert/notifier/channels/line_test.go @@ -48,7 +48,7 @@ func TestLineNotifier(t *testing.T) { "Authorization": "Bearer sometoken", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", }, - expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", + expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", expMsgError: nil, }, { name: "Multiple alerts", @@ -70,7 +70,7 @@ func TestLineNotifier(t *testing.T) { "Authorization": "Bearer sometoken", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", }, - expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A", + expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A", expMsgError: nil, }, { name: "Token missing", @@ -83,11 +83,13 @@ func TestLineNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "line_testing", - Type: "line", - Settings: settingsJSON, + Name: "line_testing", + Type: "line", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/opsgenie.go b/pkg/services/ngalert/notifier/channels/opsgenie.go index b33b806a19ce4..93d1aa3571e01 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie.go @@ -42,6 +42,12 @@ type OpsgenieNotifier struct { // NewOpsgenieNotifier is the constructor for the Opsgenie notifier func NewOpsgenieNotifier(model *NotificationChannelConfig, t *template.Template, fn GetDecryptedValueFn) (*OpsgenieNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} + } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } autoClose := model.Settings.Get("autoClose").MustBool(true) overridePriority := model.Settings.Get("overridePriority").MustBool(true) apiKey := fn(context.Background(), model.SecureSettings, "apiKey", model.Settings.Get("apiKey").MustString()) @@ -150,10 +156,10 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod var tmplErr error tmpl, data := TmplText(ctx, on.tmpl, as, on.log, &tmplErr) - title := tmpl(`{{ template "default.title" . }}`) + title := tmpl(DefaultMessageTitleEmbed) description := fmt.Sprintf( "%s\n%s\n\n%s", - tmpl(`{{ template "default.title" . }}`), + tmpl(DefaultMessageTitleEmbed), ruleURL, tmpl(`{{ template "default.message" . }}`), ) @@ -201,7 +207,7 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod apiURL = tmpl(on.APIUrl) if tmplErr != nil { - on.log.Debug("failed to template Opsgenie message", "err", tmplErr.Error()) + on.log.Warn("failed to template Opsgenie message", "err", tmplErr.Error()) } return bodyJSON, apiURL, nil diff --git a/pkg/services/ngalert/notifier/channels/opsgenie_test.go b/pkg/services/ngalert/notifier/channels/opsgenie_test.go index 8f07deb00e06b..49b13c92025cd 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie_test.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie_test.go @@ -46,7 +46,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "details": { "url": "http://localhost/alerting/list" }, @@ -71,7 +71,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", "details": { "url": "http://localhost/alerting/list" }, @@ -96,7 +96,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", "details": { "alertname": "alert1", "lbl1": "val1", @@ -128,7 +128,7 @@ func TestOpsgenieNotifier(t *testing.T) { }, expMsg: `{ "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", "details": { "alertname": "alert1", "url": "http://localhost/alerting/list" @@ -163,11 +163,13 @@ func TestOpsgenieNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, + Name: "opsgenie_testing", + Type: "opsgenie", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/pagerduty.go b/pkg/services/ngalert/notifier/channels/pagerduty.go index 1b3fb980a7260..d74a812664168 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty.go @@ -44,6 +44,9 @@ func NewPagerdutyNotifier(model *NotificationChannelConfig, t *template.Template if model.Settings == nil { return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } key := fn(context.Background(), model.SecureSettings, "integrationKey", model.Settings.Get("integrationKey").MustString()) if key == "" { @@ -69,7 +72,7 @@ func NewPagerdutyNotifier(model *NotificationChannelConfig, t *template.Template Class: model.Settings.Get("class").MustString("default"), Component: model.Settings.Get("component").MustString("Grafana"), Group: model.Settings.Get("group").MustString("default"), - Summary: model.Settings.Get("summary").MustString(`{{ template "default.title" . }}`), + Summary: model.Settings.Get("summary").MustString(DefaultMessageTitleEmbed), tmpl: t, log: log.New("alerting.notifier." + model.Name), }, nil @@ -142,7 +145,7 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m HRef: pn.tmpl.ExternalURL.String(), Text: "External URL", }}, - Description: tmpl(`{{ template "default.title" . }}`), // TODO: this can be configurable template. + Description: tmpl(DefaultMessageTitleEmbed), // TODO: this can be configurable template. Payload: pagerDutyPayload{ Component: tmpl(pn.Component), Summary: tmpl(pn.Summary), @@ -164,7 +167,7 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m } if tmplErr != nil { - pn.log.Debug("failed to template PagerDuty message", "err", tmplErr.Error()) + pn.log.Warn("failed to template PagerDuty message", "err", tmplErr.Error()) } return msg, eventType, nil diff --git a/pkg/services/ngalert/notifier/channels/pagerduty_test.go b/pkg/services/ngalert/notifier/channels/pagerduty_test.go index a3d1c329b77b2..3d62070969f63 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty_test.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty_test.go @@ -61,7 +61,7 @@ func TestPagerdutyNotifier(t *testing.T) { Component: "Grafana", Group: "default", CustomDetails: map[string]string{ - "firing": "\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "num_firing": "1", "num_resolved": "0", "resolved": "", @@ -107,7 +107,7 @@ func TestPagerdutyNotifier(t *testing.T) { Component: "My Grafana", Group: "my_group", CustomDetails: map[string]string{ - "firing": "\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", "num_firing": "2", "num_resolved": "0", "resolved": "", @@ -129,11 +129,13 @@ func TestPagerdutyNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "pageduty_testing", - Type: "pagerduty", - Settings: settingsJSON, + Name: "pageduty_testing", + Type: "pagerduty", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/pushover.go b/pkg/services/ngalert/notifier/channels/pushover.go index ad7ff9bc83061..0bd38e3fda03f 100644 --- a/pkg/services/ngalert/notifier/channels/pushover.go +++ b/pkg/services/ngalert/notifier/channels/pushover.go @@ -43,6 +43,9 @@ func NewPushoverNotifier(model *NotificationChannelConfig, t *template.Template, if model.Settings == nil { return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } userKey := fn(context.Background(), model.SecureSettings, "userKey", model.Settings.Get("userKey").MustString()) APIToken := fn(context.Background(), model.SecureSettings, "apiToken", model.Settings.Get("apiToken").MustString()) @@ -192,7 +195,7 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al } // Add title - err = w.WriteField("title", tmpl(`{{ template "default.title" . }}`)) + err = w.WriteField("title", tmpl(DefaultMessageTitleEmbed)) if err != nil { return nil, b, err } @@ -215,7 +218,7 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al } if tmplErr != nil { - pn.log.Debug("failed to template pushover message", "err", tmplErr.Error()) + pn.log.Warn("failed to template pushover message", "err", tmplErr.Error()) } // Mark as html message diff --git a/pkg/services/ngalert/notifier/channels/pushover_test.go b/pkg/services/ngalert/notifier/channels/pushover_test.go index e5d953ebf0edd..9d72f4aeec3e2 100644 --- a/pkg/services/ngalert/notifier/channels/pushover_test.go +++ b/pkg/services/ngalert/notifier/channels/pushover_test.go @@ -61,7 +61,7 @@ func TestPushoverNotifier(t *testing.T) { "title": "[FIRING:1] (val1)", "url": "http://localhost/alerting/list", "url_title": "Show alert rule", - "message": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", "html": "1", }, expMsgError: nil, @@ -137,11 +137,13 @@ func TestPushoverNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "pushover_testing", - Type: "pushover", - Settings: settingsJSON, + Name: "pushover_testing", + Type: "pushover", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/sensugo.go b/pkg/services/ngalert/notifier/channels/sensugo.go index d93e2bcb8efd7..b67d635c7138d 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo.go +++ b/pkg/services/ngalert/notifier/channels/sensugo.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "strings" - "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" @@ -34,7 +33,9 @@ func NewSensuGoNotifier(model *NotificationChannelConfig, t *template.Template, if model.Settings == nil { return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } - + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } url := model.Settings.Get("url").MustString() if url == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find URL property in settings"} @@ -118,7 +119,7 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool }, }, "output": tmpl(sn.Message), - "issued": time.Now().Unix(), + "issued": timeNow().Unix(), "interval": 86400, "status": status, "handlers": handlers, @@ -127,7 +128,7 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool } if tmplErr != nil { - sn.log.Debug("failed to template sensugo message", "err", tmplErr.Error()) + sn.log.Warn("failed to template sensugo message", "err", tmplErr.Error()) } body, err := json.Marshal(bodyMsgType) diff --git a/pkg/services/ngalert/notifier/channels/sensugo_test.go b/pkg/services/ngalert/notifier/channels/sensugo_test.go index a84053928d956..c15e3ae3426bb 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo_test.go +++ b/pkg/services/ngalert/notifier/channels/sensugo_test.go @@ -20,6 +20,9 @@ import ( ) func TestSensuGoNotifier(t *testing.T) { + constNow := time.Now() + defer mockTimeNow(constNow)() + tmpl := templateForTests(t) externalURL, err := url.Parse("http://localhost") @@ -59,8 +62,8 @@ func TestSensuGoNotifier(t *testing.T) { "ruleURL": "http://localhost/alerting/list", }, }, - "output": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", - "issued": time.Now().Unix(), + "output": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "issued": timeNow().Unix(), "interval": 86400, "status": 2, "handlers": nil, @@ -107,7 +110,7 @@ func TestSensuGoNotifier(t *testing.T) { }, }, "output": "2 alerts are firing, 0 are resolved", - "issued": time.Now().Unix(), + "issued": timeNow().Unix(), "interval": 86400, "status": 2, "handlers": []string{"myhandler"}, @@ -134,11 +137,13 @@ func TestSensuGoNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "Sensu Go", - Type: "sensugo", - Settings: settingsJSON, + Name: "Sensu Go", + Type: "sensugo", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 58d0a045f9b4e..66713312ed82a 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -48,6 +48,9 @@ func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn if model.Settings == nil { return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } slackURL := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString()) if slackURL == "" { @@ -115,7 +118,7 @@ func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn IconURL: model.Settings.Get("icon_url").MustString(), Token: token, Text: model.Settings.Get("text").MustString(`{{ template "default.message" . }}`), - Title: model.Settings.Get("title").MustString(`{{ template "default.title" . }}`), + Title: model.Settings.Get("title").MustString(DefaultMessageTitleEmbed), log: log.New("alerting.notifier.slack"), tmpl: t, }, nil @@ -266,7 +269,7 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler }, } if tmplErr != nil { - sn.log.Debug("failed to template Slack message", "err", tmplErr.Error()) + sn.log.Warn("failed to template Slack message", "err", tmplErr.Error()) } mentionsBuilder := strings.Builder{} diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index 109301f8b5dad..ba5db25a47a29 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -58,7 +58,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v", @@ -93,7 +93,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "[FIRING:1] (val1)", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n", Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v", @@ -135,7 +135,7 @@ func TestSlackNotifier(t *testing.T) { { Title: "2 firing, 0 resolved", TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", Fallback: "2 firing, 0 resolved", Fields: nil, Footer: "Grafana v", @@ -165,11 +165,13 @@ func TestSlackNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "slack_testing", - Type: "slack", - Settings: settingsJSON, + Name: "slack_testing", + Type: "slack", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) @@ -301,6 +303,7 @@ func TestSendSlackRequest(t *testing.T) { _, err := w.Write([]byte(test.slackResponse)) require.NoError(tt, err) })) + defer server.Close() req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(tt, err) diff --git a/pkg/services/ngalert/notifier/channels/teams.go b/pkg/services/ngalert/notifier/channels/teams.go index 2dfa492785142..00a56c608d688 100644 --- a/pkg/services/ngalert/notifier/channels/teams.go +++ b/pkg/services/ngalert/notifier/channels/teams.go @@ -56,7 +56,7 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, ruleURL := joinUrlPath(tn.tmpl.ExternalURL.String(), "/alerting/list", tn.log) - title := tmpl(`{{ template "default.title" . }}`) + title := tmpl(DefaultMessageTitleEmbed) body := map[string]interface{}{ "@type": "MessageCard", "@context": "http://schema.org/extensions", @@ -88,7 +88,7 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, u := tmpl(tn.URL) if tmplErr != nil { - tn.log.Debug("failed to template Teams message", "err", tmplErr.Error()) + tn.log.Warn("failed to template Teams message", "err", tmplErr.Error()) } b, err := json.Marshal(&body) diff --git a/pkg/services/ngalert/notifier/channels/teams_test.go b/pkg/services/ngalert/notifier/channels/teams_test.go index 88fecc6865f98..26d5d05ce68ff 100644 --- a/pkg/services/ngalert/notifier/channels/teams_test.go +++ b/pkg/services/ngalert/notifier/channels/teams_test.go @@ -51,7 +51,7 @@ func TestTeamsNotifier(t *testing.T) { "sections": []map[string]interface{}{ { "title": "Details", - "text": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, }, "potentialAction": []map[string]interface{}{ diff --git a/pkg/services/ngalert/notifier/channels/telegram.go b/pkg/services/ngalert/notifier/channels/telegram.go index cea09366e0f61..c3902eeccf634 100644 --- a/pkg/services/ngalert/notifier/channels/telegram.go +++ b/pkg/services/ngalert/notifier/channels/telegram.go @@ -33,6 +33,9 @@ func NewTelegramNotifier(model *NotificationChannelConfig, t *template.Template, if model.Settings == nil { return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } botToken := fn(context.Background(), model.SecureSettings, "bottoken", model.Settings.Get("bottoken").MustString()) chatID := model.Settings.Get("chatid").MustString() @@ -124,7 +127,7 @@ func (tn *TelegramNotifier) buildTelegramMessage(ctx context.Context, as []*type message := tmpl(tn.Message) if tmplErr != nil { - tn.log.Debug("failed to template Telegram message", "err", tmplErr.Error()) + tn.log.Warn("failed to template Telegram message", "err", tmplErr.Error()) } msg["text"] = message diff --git a/pkg/services/ngalert/notifier/channels/telegram_test.go b/pkg/services/ngalert/notifier/channels/telegram_test.go index b7e124a887ebd..72714e7509355 100644 --- a/pkg/services/ngalert/notifier/channels/telegram_test.go +++ b/pkg/services/ngalert/notifier/channels/telegram_test.go @@ -48,7 +48,7 @@ func TestTelegramNotifier(t *testing.T) { expMsg: map[string]string{ "chat_id": "someid", "parse_mode": "html", - "text": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", }, expMsgError: nil, }, { @@ -75,7 +75,7 @@ func TestTelegramNotifier(t *testing.T) { expMsg: map[string]string{ "chat_id": "someid", "parse_mode": "html", - "text": "__Custom Firing__\n2 Firing\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + "text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", }, expMsgError: nil, }, { @@ -89,11 +89,13 @@ func TestTelegramNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "telegram_testing", - Type: "telegram", - Settings: settingsJSON, + Name: "telegram_testing", + Type: "telegram", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/threema.go b/pkg/services/ngalert/notifier/channels/threema.go index d1ed324451187..cef18715409ec 100644 --- a/pkg/services/ngalert/notifier/channels/threema.go +++ b/pkg/services/ngalert/notifier/channels/threema.go @@ -35,7 +35,9 @@ func NewThreemaNotifier(model *NotificationChannelConfig, t *template.Template, if model.Settings == nil { return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} } - + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} + } gatewayID := model.Settings.Get("gateway_id").MustString() recipientID := model.Settings.Get("recipient_id").MustString() apiSecret := fn(context.Background(), model.SecureSettings, "api_secret", model.Settings.Get("api_secret").MustString()) @@ -99,14 +101,14 @@ func (tn *ThreemaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool // Build message message := fmt.Sprintf("%s%s\n\n*Message:*\n%s\n*URL:* %s\n", stateEmoji, - tmpl(`{{ template "default.title" . }}`), + tmpl(DefaultMessageTitleEmbed), tmpl(`{{ template "default.message" . }}`), path.Join(tn.tmpl.ExternalURL.String(), "/alerting/list"), ) data.Set("text", message) if tmplErr != nil { - tn.log.Debug("failed to template Threema message", "err", tmplErr.Error()) + tn.log.Warn("failed to template Threema message", "err", tmplErr.Error()) } cmd := &models.SendWebhookSync{ diff --git a/pkg/services/ngalert/notifier/channels/threema_test.go b/pkg/services/ngalert/notifier/channels/threema_test.go index dac8d345719fd..e6393e26c4cb1 100644 --- a/pkg/services/ngalert/notifier/channels/threema_test.go +++ b/pkg/services/ngalert/notifier/channels/threema_test.go @@ -47,7 +47,7 @@ func TestThreemaNotifier(t *testing.T) { }, }, }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", + expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", expMsgError: nil, }, { name: "Multiple alerts", @@ -69,7 +69,7 @@ func TestThreemaNotifier(t *testing.T) { }, }, }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", + expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253Dalert1%252Clbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A&to=87654321", expMsgError: nil, }, { name: "Invalid gateway id", @@ -101,11 +101,13 @@ func TestThreemaNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, + Name: "threema_testing", + Type: "threema", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/channels/victorops.go b/pkg/services/ngalert/notifier/channels/victorops.go index 39d9dfaf9ad06..f4b08b8ce0a17 100644 --- a/pkg/services/ngalert/notifier/channels/victorops.go +++ b/pkg/services/ngalert/notifier/channels/victorops.go @@ -28,6 +28,10 @@ const ( // NewVictoropsNotifier creates an instance of VictoropsNotifier that // handles posting notifications to Victorops REST API func NewVictoropsNotifier(model *NotificationChannelConfig, t *template.Template) (*VictoropsNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} + } + url := model.Settings.Get("url").MustString() if url == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find victorops url property in settings"} @@ -83,7 +87,7 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo bodyJSON := simplejson.New() bodyJSON.Set("message_type", messageType) bodyJSON.Set("entity_id", groupKey.Hash()) - bodyJSON.Set("entity_display_name", tmpl(`{{ template "default.title" . }}`)) + bodyJSON.Set("entity_display_name", tmpl(DefaultMessageTitleEmbed)) bodyJSON.Set("timestamp", time.Now().Unix()) bodyJSON.Set("state_message", tmpl(`{{ template "default.message" . }}`)) bodyJSON.Set("monitoring_tool", "Grafana v"+setting.BuildVersion) @@ -93,7 +97,7 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo u := tmpl(vn.URL) if tmplErr != nil { - vn.log.Debug("failed to template VictorOps message", "err", tmplErr.Error()) + vn.log.Warn("failed to template VictorOps message", "err", tmplErr.Error()) } b, err := bodyJSON.MarshalJSON() diff --git a/pkg/services/ngalert/notifier/channels/victorops_test.go b/pkg/services/ngalert/notifier/channels/victorops_test.go index b98fcac6663e4..e9f94d59bec94 100644 --- a/pkg/services/ngalert/notifier/channels/victorops_test.go +++ b/pkg/services/ngalert/notifier/channels/victorops_test.go @@ -47,7 +47,7 @@ func TestVictoropsNotifier(t *testing.T) { "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", "message_type": "CRITICAL", "monitoring_tool": "Grafana v", - "state_message": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n" + "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n" }`, expMsgError: nil, }, { @@ -72,7 +72,7 @@ func TestVictoropsNotifier(t *testing.T) { "entity_id": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", "message_type": "CRITICAL", "monitoring_tool": "Grafana v", - "state_message": "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n" + "state_message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n" }`, expMsgError: nil, }, { diff --git a/pkg/services/ngalert/notifier/channels/webhook.go b/pkg/services/ngalert/notifier/channels/webhook.go index c2d2ea3f8e860..480cf62c0c130 100644 --- a/pkg/services/ngalert/notifier/channels/webhook.go +++ b/pkg/services/ngalert/notifier/channels/webhook.go @@ -31,7 +31,10 @@ type WebhookNotifier struct { // the WebHook notifier. func NewWebHookNotifier(model *NotificationChannelConfig, t *template.Template, fn GetDecryptedValueFn) (*WebhookNotifier, error) { if model.Settings == nil { - return nil, receiverInitError{Cfg: *model, Reason: "could not find settings property"} + return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"} + } + if model.SecureSettings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "no secure settings supplied"} } url := model.Settings.Get("url").MustString() if url == "" { @@ -89,7 +92,7 @@ func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool GroupKey: groupKey.String(), TruncatedAlerts: numTruncated, OrgID: wn.orgID, - Title: tmpl(`{{ template "default.title" . }}`), + Title: tmpl(DefaultMessageTitleEmbed), Message: tmpl(`{{ template "default.message" . }}`), } if types.Alerts(as...).Status() == model.AlertFiring { @@ -99,7 +102,7 @@ func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool } if tmplErr != nil { - wn.log.Debug("failed to template webhook message", "err", tmplErr.Error()) + wn.log.Warn("failed to template webhook message", "err", tmplErr.Error()) } body, err := json.Marshal(msg) diff --git a/pkg/services/ngalert/notifier/channels/webhook_test.go b/pkg/services/ngalert/notifier/channels/webhook_test.go index 4e6272a51ca1a..943d77881972e 100644 --- a/pkg/services/ngalert/notifier/channels/webhook_test.go +++ b/pkg/services/ngalert/notifier/channels/webhook_test.go @@ -89,7 +89,7 @@ func TestWebhookNotifier(t *testing.T) { GroupKey: "alertname", Title: "[FIRING:1] (val1)", State: "alerting", - Message: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", OrgID: orgID, }, expMsgError: nil, @@ -167,7 +167,7 @@ func TestWebhookNotifier(t *testing.T) { TruncatedAlerts: 1, Title: "[FIRING:2] ", State: "alerting", - Message: "**Firing**\n\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", + Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matchers=alertname%3Dalert1%2Clbl1%3Dval2\n", OrgID: orgID, }, expMsgError: nil, @@ -182,12 +182,14 @@ func TestWebhookNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON, err := simplejson.NewJson([]byte(c.settings)) require.NoError(t, err) + secureSettings := make(map[string][]byte) m := &NotificationChannelConfig{ - Name: "webhook_testing", - Type: "webhook", - Settings: settingsJSON, - OrgID: orgID, + OrgID: orgID, + Name: "webhook_testing", + Type: "webhook", + Settings: settingsJSON, + SecureSettings: secureSettings, } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) diff --git a/pkg/services/ngalert/notifier/config.go b/pkg/services/ngalert/notifier/config.go index 1f31910b55a1f..1879773bd387a 100644 --- a/pkg/services/ngalert/notifier/config.go +++ b/pkg/services/ngalert/notifier/config.go @@ -34,7 +34,7 @@ func PersistTemplates(cfg *api.PostableUserConfig, path string) ([]string, bool, pathSet[file] = struct{}{} // Check if the template file already exists and if it has changed - // We can safeily ignore gosec here and we've previously checked the filename is clean + // We can safely ignore gosec here as we've previously checked the filename is clean // nolint:gosec if tmpl, err := ioutil.ReadFile(file); err == nil && string(tmpl) == content { // Templates file is the same we have, no-op and continue. @@ -43,10 +43,11 @@ func PersistTemplates(cfg *api.PostableUserConfig, path string) ([]string, bool, return nil, false, err } + // We can safely ignore gosec here as we've previously checked the filename is clean + // nolint:gosec if err := ioutil.WriteFile(file, []byte(content), 0644); err != nil { return nil, false, fmt.Errorf("unable to create Alertmanager template file %q: %s", file, err) } - // nolint:gosec templatesChanged = true } diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 64f408271d4e5..32917af897ea5 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -78,6 +78,7 @@ func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore store.AlertingStore, cluster.DefaultProbeTimeout, cluster.DefaultProbeInterval, nil, + true, ) if err != nil { diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/receivers.go index 6fbcd9f687546..8b48aae9c8753 100644 --- a/pkg/services/ngalert/notifier/receivers.go +++ b/pkg/services/ngalert/notifier/receivers.go @@ -200,7 +200,8 @@ func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei func newTestAlert(c apimodels.TestReceiversConfigParams, startsAt, updatedAt time.Time) types.Alert { var ( defaultAnnotations = model.LabelSet{ - "summary": "Notification test", + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]", } defaultLabels = model.LabelSet{ "alertname": "TestAlert", diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/schedule/compat.go index 5892840a3e7aa..a382570795f78 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/schedule/compat.go @@ -19,6 +19,7 @@ import ( const ( NoDataAlertName = "DatasourceNoData" + ErrorAlertName = "DatasourceError" Rulename = "rulename" ) @@ -52,6 +53,10 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post return noDataAlert(nL, nA, alertState, urlStr) } + if alertState.State == eval.Error { + return errorAlert(nL, nA, alertState, urlStr) + } + return &models.PostableAlert{ Annotations: models.LabelSet(nA), StartsAt: strfmt.DateTime(alertState.StartsAt), @@ -84,6 +89,25 @@ func noDataAlert(labels data.Labels, annotations data.Labels, alertState *state. } } +// errorAlert is a special alert sent when evaluation of an alert rule failed due to an error. Like noDataAlert, it +// replaces the old behaviour of "Keep Last State" creating a separate alert called DatasourceError. +func errorAlert(labels, annotations data.Labels, alertState *state.State, urlStr string) *models.PostableAlert { + if name, ok := labels[model.AlertNameLabel]; ok { + labels[Rulename] = name + } + labels[model.AlertNameLabel] = ErrorAlertName + + return &models.PostableAlert{ + Annotations: models.LabelSet(annotations), + StartsAt: strfmt.DateTime(alertState.StartsAt), + EndsAt: strfmt.DateTime(alertState.EndsAt), + Alert: models.Alert{ + Labels: models.LabelSet(labels), + GeneratorURL: strfmt.URI(urlStr), + }, + } +} + func FromAlertStateToPostableAlerts(firingStates []*state.State, stateManager *state.Manager, appURL *url.URL) apimodels.PostableAlerts { alerts := apimodels.PostableAlerts{PostableAlerts: make([]models.PostableAlert, 0, len(firingStates))} var sentAlerts []*state.State diff --git a/pkg/services/ngalert/schedule/compat_test.go b/pkg/services/ngalert/schedule/compat_test.go index 05af8c100312f..e8eafc98ba785 100644 --- a/pkg/services/ngalert/schedule/compat_test.go +++ b/pkg/services/ngalert/schedule/compat_test.go @@ -152,6 +152,35 @@ func Test_stateToPostableAlert(t *testing.T) { require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename) }) }) + case eval.Error: + t.Run("should keep existing labels and change name", func(t *testing.T) { + alertState := randomState(tc.state) + alertState.Labels = randomMapOfStrings() + alertName := util.GenerateShortUID() + alertState.Labels[model.AlertNameLabel] = alertName + + result := stateToPostableAlert(alertState, appURL) + + expected := make(models.LabelSet, len(alertState.Labels)+1) + for k, v := range alertState.Labels { + expected[k] = v + } + expected[model.AlertNameLabel] = ErrorAlertName + expected[Rulename] = alertName + + require.Equal(t, expected, result.Labels) + + t.Run("should not backup original alert name if it does not exist", func(t *testing.T) { + alertState := randomState(tc.state) + alertState.Labels = randomMapOfStrings() + delete(alertState.Labels, model.AlertNameLabel) + + result := stateToPostableAlert(alertState, appURL) + + require.Equal(t, ErrorAlertName, result.Labels[model.AlertNameLabel]) + require.NotContains(t, result.Labels[model.AlertNameLabel], Rulename) + }) + }) default: t.Run("should copy labels as is", func(t *testing.T) { alertState := randomState(tc.state) diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index e6cc980f0f668..3cdd6eb012067 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -111,7 +111,7 @@ func NewScheduler(cfg SchedulerCfg, expressionService *expr.Service, appURL *url ticker := alerting.NewTicker(cfg.C.Now(), time.Second*0, cfg.C, int64(cfg.BaseInterval.Seconds())) sch := schedule{ - registry: alertRuleRegistry{alertRuleInfo: make(map[models.AlertRuleKey]alertRuleInfo)}, + registry: alertRuleRegistry{alertRuleInfo: make(map[models.AlertRuleKey]*alertRuleInfo)}, maxAttempts: cfg.MaxAttempts, clock: cfg.C, baseInterval: cfg.BaseInterval, @@ -340,15 +340,15 @@ func (sch *schedule) ruleEvaluationLoop(ctx context.Context) error { type readyToRunItem struct { key models.AlertRuleKey - ruleInfo alertRuleInfo + ruleInfo *alertRuleInfo + version int64 } readyToRun := make([]readyToRunItem, 0) for _, item := range alertRules { key := item.GetKey() itemVersion := item.Version - newRoutine := !sch.registry.exists(key) - ruleInfo := sch.registry.getOrCreateInfo(key, itemVersion) + ruleInfo, newRoutine := sch.registry.getOrCreateInfo(ctx, key) // enforce minimum evaluation interval if item.IntervalSeconds < int64(sch.minRuleInterval.Seconds()) { @@ -360,7 +360,7 @@ func (sch *schedule) ruleEvaluationLoop(ctx context.Context) error { if newRoutine && !invalidInterval { dispatcherGroup.Go(func() error { - return sch.ruleRoutine(ctx, key, ruleInfo.evalCh, ruleInfo.stopCh) + return sch.ruleRoutine(ruleInfo.ctx, key, ruleInfo.evalCh) }) } @@ -373,7 +373,7 @@ func (sch *schedule) ruleEvaluationLoop(ctx context.Context) error { itemFrequency := item.IntervalSeconds / int64(sch.baseInterval.Seconds()) if item.IntervalSeconds != 0 && tickNum%itemFrequency == 0 { - readyToRun = append(readyToRun, readyToRunItem{key: key, ruleInfo: ruleInfo}) + readyToRun = append(readyToRun, readyToRunItem{key: key, ruleInfo: ruleInfo, version: itemVersion}) } // remove the alert rule from the registered alert rules @@ -389,19 +389,21 @@ func (sch *schedule) ruleEvaluationLoop(ctx context.Context) error { item := readyToRun[i] time.AfterFunc(time.Duration(int64(i)*step), func() { - item.ruleInfo.evalCh <- &evalContext{now: tick, version: item.ruleInfo.version} + success := item.ruleInfo.eval(tick, item.version) + if !success { + sch.log.Debug("Scheduled evaluation was canceled because evaluation routine was stopped", "uid", item.key.UID, "org", item.key.OrgID, "time", tick) + } }) } // unregister and stop routines of the deleted alert rules for key := range registeredDefinitions { - ruleInfo, err := sch.registry.get(key) - if err != nil { - sch.log.Error("failed to get alert rule routine information", "err", err) + ruleInfo, ok := sch.registry.del(key) + if !ok { + sch.log.Error("unable to delete alert rule routine information because it did not exist", "uid", key.UID, "org_id", key.OrgID) continue } - ruleInfo.stopCh <- struct{}{} - sch.registry.del(key) + ruleInfo.stop() } case <-ctx.Done(): waitErr := dispatcherGroup.Wait() @@ -421,7 +423,7 @@ func (sch *schedule) ruleEvaluationLoop(ctx context.Context) error { } } -func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRuleKey, evalCh <-chan *evalContext, stopCh <-chan struct{}) error { +func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRuleKey, evalCh <-chan *evalContext) error { logger := sch.log.New("uid", key.UID, "org", key.OrgID) logger.Debug("alert rule routine started") @@ -516,9 +518,14 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul evalRunning := false var currentRule *models.AlertRule + defer sch.stopApplied(key) for { select { - case ctx := <-evalCh: + case ctx, ok := <-evalCh: + if !ok { + logger.Debug("Evaluation channel has been closed. Exiting") + return nil + } if evalRunning { continue } @@ -546,13 +553,9 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key models.AlertRul logger.Error("evaluation failed after all retries", "err", err) } }() - case <-stopCh: - sch.stopApplied(key) + case <-grafanaCtx.Done(): logger.Debug("stopping alert rule routine") - // interrupt evaluation if it's running return nil - case <-grafanaCtx.Done(): - return grafanaCtx.Err() } } } @@ -578,51 +581,34 @@ func (sch *schedule) saveAlertStates(states []*state.State) { type alertRuleRegistry struct { mu sync.Mutex - alertRuleInfo map[models.AlertRuleKey]alertRuleInfo + alertRuleInfo map[models.AlertRuleKey]*alertRuleInfo } -// getOrCreateInfo returns the channel for the specific alert rule -// if it does not exists creates one and returns it -func (r *alertRuleRegistry) getOrCreateInfo(key models.AlertRuleKey, ruleVersion int64) alertRuleInfo { +// getOrCreateInfo gets rule routine information from registry by the key. If it does not exist, it creates a new one. +// Returns a pointer to the rule routine information and a flag that indicates whether it is a new struct or not. +func (r *alertRuleRegistry) getOrCreateInfo(context context.Context, key models.AlertRuleKey) (*alertRuleInfo, bool) { r.mu.Lock() defer r.mu.Unlock() info, ok := r.alertRuleInfo[key] if !ok { - r.alertRuleInfo[key] = alertRuleInfo{evalCh: make(chan *evalContext), stopCh: make(chan struct{}), version: ruleVersion} - return r.alertRuleInfo[key] + info = newAlertRuleInfo(context) + r.alertRuleInfo[key] = info } - info.version = ruleVersion - r.alertRuleInfo[key] = info - return info + return info, !ok } -// get returns the channel for the specific alert rule -// if the key does not exist returns an error -func (r *alertRuleRegistry) get(key models.AlertRuleKey) (*alertRuleInfo, error) { +// del removes pair that has specific key from alertRuleInfo. +// Returns 2-tuple where the first element is value of the removed pair +// and the second element indicates whether element with the specified key existed. +func (r *alertRuleRegistry) del(key models.AlertRuleKey) (*alertRuleInfo, bool) { r.mu.Lock() defer r.mu.Unlock() - info, ok := r.alertRuleInfo[key] - if !ok { - return nil, fmt.Errorf("%v key not found", key) + if ok { + delete(r.alertRuleInfo, key) } - return &info, nil -} - -func (r *alertRuleRegistry) exists(key models.AlertRuleKey) bool { - r.mu.Lock() - defer r.mu.Unlock() - - _, ok := r.alertRuleInfo[key] - return ok -} - -func (r *alertRuleRegistry) del(key models.AlertRuleKey) { - r.mu.Lock() - defer r.mu.Unlock() - - delete(r.alertRuleInfo, key) + return info, ok } func (r *alertRuleRegistry) iter() <-chan models.AlertRuleKey { @@ -651,9 +637,27 @@ func (r *alertRuleRegistry) keyMap() map[models.AlertRuleKey]struct{} { } type alertRuleInfo struct { - evalCh chan *evalContext - stopCh chan struct{} - version int64 + evalCh chan *evalContext + ctx context.Context + stop context.CancelFunc +} + +func newAlertRuleInfo(parent context.Context) *alertRuleInfo { + ctx, cancel := context.WithCancel(parent) + return &alertRuleInfo{evalCh: make(chan *evalContext), ctx: ctx, stop: cancel} +} + +// eval signals the rule evaluation routine to perform the evaluation of the rule. Does nothing if the loop is stopped +func (a *alertRuleInfo) eval(t time.Time, version int64) bool { + select { + case a.evalCh <- &evalContext{ + now: t, + version: version, + }: + return true + case <-a.ctx.Done(): + return false + } } type evalContext struct { diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 62d93fe522934..bbc7973242a3b 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -7,6 +7,8 @@ import ( "fmt" "math/rand" "net/url" + "runtime" + "sync" "testing" "time" @@ -19,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/annotations" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -271,11 +274,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { rule := CreateTestAlertRule(t, ruleStore, 10, rand.Int63(), evalState) go func() { - stop := make(chan struct{}) - t.Cleanup(func() { - close(stop) - }) - _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan) }() expectedTime := time.UnixMicro(rand.Int63()) @@ -366,35 +367,19 @@ func TestSchedule_ruleRoutine(t *testing.T) { } t.Run("should exit", func(t *testing.T) { - t.Run("when we signal it to stop", func(t *testing.T) { - stopChan := make(chan struct{}) - stoppedChan := make(chan error) - - sch, _, _, _, _ := createSchedule(make(chan time.Time)) - - go func() { - err := sch.ruleRoutine(context.Background(), models.AlertRuleKey{}, make(chan *evalContext), stopChan) - stoppedChan <- err - }() - - stopChan <- struct{}{} - err := waitForErrChannel(t, stoppedChan) - require.NoError(t, err) - }) - t.Run("when context is cancelled", func(t *testing.T) { stoppedChan := make(chan error) sch, _, _, _, _ := createSchedule(make(chan time.Time)) ctx, cancel := context.WithCancel(context.Background()) go func() { - err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evalContext), make(chan struct{})) + err := sch.ruleRoutine(ctx, models.AlertRuleKey{}, make(chan *evalContext)) stoppedChan <- err }() cancel() err := waitForErrChannel(t, stoppedChan) - require.ErrorIs(t, err, context.Canceled) + require.NoError(t, err) }) }) @@ -407,11 +392,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { rule := CreateTestAlertRule(t, ruleStore, 10, rand.Int63(), randomNormalState()) go func() { - stop := make(chan struct{}) - t.Cleanup(func() { - close(stop) - }) - _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan) }() expectedTime := time.UnixMicro(rand.Int63()) @@ -461,11 +444,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { rule := CreateTestAlertRule(t, ruleStore, 10, rand.Int63(), randomNormalState()) go func() { - stop := make(chan struct{}) - t.Cleanup(func() { - close(stop) - }) - _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan) }() expectedTime := time.UnixMicro(rand.Int63()) @@ -546,11 +527,9 @@ func TestSchedule_ruleRoutine(t *testing.T) { rule := CreateTestAlertRule(t, ruleStore, 10, orgID, eval.Alerting) go func() { - stop := make(chan struct{}) - t.Cleanup(func() { - close(stop) - }) - _ = sch.ruleRoutine(context.Background(), rule.GetKey(), evalChan, stop) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _ = sch.ruleRoutine(ctx, rule.GetKey(), evalChan) }() evalChan <- &evalContext{ @@ -573,9 +552,95 @@ func TestSchedule_ruleRoutine(t *testing.T) { }) } +func TestSchedule_alertRuleInfo(t *testing.T) { + t.Run("when rule evaluation is not stopped", func(t *testing.T) { + t.Run("eval should send to evalCh", func(t *testing.T) { + r := newAlertRuleInfo(context.Background()) + expected := time.Now() + resultCh := make(chan bool) + version := rand.Int63() + go func() { + resultCh <- r.eval(expected, version) + }() + select { + case ctx := <-r.evalCh: + require.Equal(t, version, ctx.version) + require.Equal(t, expected, ctx.now) + require.True(t, <-resultCh) + case <-time.After(5 * time.Second): + t.Fatal("No message was received on eval channel") + } + }) + t.Run("eval should exit when context is cancelled", func(t *testing.T) { + r := newAlertRuleInfo(context.Background()) + resultCh := make(chan bool) + go func() { + resultCh <- r.eval(time.Now(), rand.Int63()) + }() + runtime.Gosched() + r.stop() + select { + case result := <-resultCh: + require.False(t, result) + case <-time.After(5 * time.Second): + t.Fatal("No message was received on eval channel") + } + }) + }) + t.Run("when rule evaluation is stopped", func(t *testing.T) { + t.Run("eval should do nothing", func(t *testing.T) { + r := newAlertRuleInfo(context.Background()) + r.stop() + require.False(t, r.eval(time.Now(), rand.Int63())) + }) + t.Run("stop should do nothing", func(t *testing.T) { + r := newAlertRuleInfo(context.Background()) + r.stop() + r.stop() + }) + }) + t.Run("should be thread-safe", func(t *testing.T) { + r := newAlertRuleInfo(context.Background()) + wg := sync.WaitGroup{} + go func() { + for { + select { + case <-r.evalCh: + time.Sleep(time.Millisecond) + case <-r.ctx.Done(): + return + } + } + }() + + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + for i := 0; i < 20; i++ { + max := 2 + if i <= 10 { + max = 1 + } + switch rand.Intn(max) + 1 { + case 1: + r.eval(time.Now(), rand.Int63()) + case 2: + r.stop() + } + } + wg.Done() + }() + } + + wg.Wait() + }) +} + func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, acs store.AdminConfigurationStore, registry *prometheus.Registry) (*schedule, *clock.Mock) { t.Helper() + fakeAnnoRepo := NewFakeAnnotationsRepo() + annotations.SetRepository(fakeAnnoRepo) mockedClock := clock.NewMock() logger := log.New("ngalert schedule test") if registry == nil { diff --git a/pkg/services/ngalert/schedule/testing.go b/pkg/services/ngalert/schedule/testing.go index 8ae16425ac933..516f3be54ec27 100644 --- a/pkg/services/ngalert/schedule/testing.go +++ b/pkg/services/ngalert/schedule/testing.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/services/annotations" + models2 "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -379,3 +381,47 @@ func (am *FakeExternalAlertmanager) Handler() func(w http.ResponseWriter, r *htt func (am *FakeExternalAlertmanager) Close() { am.server.Close() } + +type FakeAnnotationsRepo struct { + mtx sync.Mutex + items []*annotations.Item +} + +func NewFakeAnnotationsRepo() *FakeAnnotationsRepo { + return &FakeAnnotationsRepo{ + items: make([]*annotations.Item, 0), + } +} + +func (repo *FakeAnnotationsRepo) Len() int { + repo.mtx.Lock() + defer repo.mtx.Unlock() + return len(repo.items) +} + +func (repo *FakeAnnotationsRepo) Delete(params *annotations.DeleteParams) error { + return nil +} + +func (repo *FakeAnnotationsRepo) Save(item *annotations.Item) error { + repo.mtx.Lock() + defer repo.mtx.Unlock() + repo.items = append(repo.items, item) + + return nil +} +func (repo *FakeAnnotationsRepo) Update(item *annotations.Item) error { + return nil +} + +func (repo *FakeAnnotationsRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) { + annotations := []*annotations.ItemDTO{{Id: 1}} + return annotations, nil +} + +func (repo *FakeAnnotationsRepo) FindTags(query *annotations.TagsQuery) (annotations.FindTagsResult, error) { + result := annotations.FindTagsResult{ + Tags: []*annotations.TagsDTO{}, + } + return result, nil +} diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index cce76407be732..968419368a125 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -234,45 +234,46 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { } func (st *Manager) createAlertAnnotation(ctx context.Context, new eval.State, alertRule *ngModels.AlertRule, result eval.Result, oldState eval.State) { - st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", new.String()) - dashUid, ok := alertRule.Annotations[ngModels.DashboardUIDAnnotation] - if !ok { - return - } + st.log.Debug("alert state changed creating annotation", "alertRuleUID", alertRule.UID, "newState", new.String(), "oldState", oldState.String()) - panelUid := alertRule.Annotations[ngModels.PanelIDAnnotation] + annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, result.Instance.String(), new.String()) - panelId, err := strconv.ParseInt(panelUid, 10, 64) - if err != nil { - st.log.Error("error parsing panelUID for alert annotation", "panelUID", panelUid, "alertRuleUID", alertRule.UID, "error", err.Error()) - return + item := &annotations.Item{ + AlertId: alertRule.ID, + OrgId: alertRule.OrgID, + PrevState: oldState.String(), + NewState: new.String(), + Text: annotationText, + Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond), } - query := &models.GetDashboardQuery{ - Uid: dashUid, - OrgId: alertRule.OrgID, - } + dashUid, ok := alertRule.Annotations[ngModels.DashboardUIDAnnotation] + if ok { + panelUid := alertRule.Annotations[ngModels.PanelIDAnnotation] - err = sqlstore.GetDashboard(ctx, query) - if err != nil { - st.log.Error("error getting dashboard for alert annotation", "dashboardUID", dashUid, "alertRuleUID", alertRule.UID, "error", err.Error()) - return - } + panelId, err := strconv.ParseInt(panelUid, 10, 64) + if err != nil { + st.log.Error("error parsing panelUID for alert annotation", "panelUID", panelUid, "alertRuleUID", alertRule.UID, "error", err.Error()) + return + } - annotationText := fmt.Sprintf("%s {%s} - %s", alertRule.Title, result.Instance.String(), new.String()) + query := &models.GetDashboardQuery{ + Uid: dashUid, + OrgId: alertRule.OrgID, + } - item := &annotations.Item{ - OrgId: alertRule.OrgID, - DashboardId: query.Result.Id, - PanelId: panelId, - PrevState: oldState.String(), - NewState: new.String(), - Text: annotationText, - Epoch: result.EvaluatedAt.UnixNano() / int64(time.Millisecond), + err = sqlstore.GetDashboard(ctx, query) + if err != nil { + st.log.Error("error getting dashboard for alert annotation", "dashboardUID", dashUid, "alertRuleUID", alertRule.UID, "error", err.Error()) + return + } + + item.PanelId = panelId + item.DashboardId = query.Result.Id } annotationRepo := annotations.GetRepository() - if err = annotationRepo.Save(item); err != nil { + if err := annotationRepo.Save(item); err != nil { st.log.Error("error saving alert annotation", "alertRuleUID", alertRule.UID, "error", err.Error()) return } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 8d87c9b1cc134..ee2b4fbeabe8b 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -2,11 +2,16 @@ package state_test import ( "context" + "errors" + "fmt" "testing" "time" + "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -30,10 +35,11 @@ func TestProcessEvalResults(t *testing.T) { evaluationDuration := 10 * time.Millisecond testCases := []struct { - desc string - alertRule *models.AlertRule - evalResults []eval.Results - expectedStates map[string]*state.State + desc string + alertRule *models.AlertRule + evalResults []eval.Results + expectedStates map[string]*state.State + expectedAnnotations int }{ { desc: "a cache entry is correctly created", @@ -109,6 +115,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid"],["alertname","test_title"],["instance_label_1","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid", @@ -249,6 +256,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -320,6 +328,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 2, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -413,6 +422,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 3, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -503,6 +513,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 2, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -576,6 +587,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -639,6 +651,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -702,6 +715,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -765,6 +779,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -828,6 +843,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -891,6 +907,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -956,6 +973,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -1078,6 +1096,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -1142,6 +1161,7 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + expectedAnnotations: 1, expectedStates: map[string]*state.State{ `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { AlertRuleUID: "test_alert_rule_uid_2", @@ -1175,6 +1195,268 @@ func TestProcessEvalResults(t *testing.T) { }, }, }, + { + desc: "normal -> error when result is Error and ExecErrState is Error", + alertRule: &models.AlertRule{ + OrgID: 1, + Title: "test_title", + UID: "test_alert_rule_uid_2", + NamespaceUID: "test_namespace_uid", + Data: []models.AlertQuery{{ + RefID: "A", + DatasourceUID: "datasource_uid_1", + }}, + Annotations: map[string]string{"annotation": "test"}, + Labels: map[string]string{"label": "test"}, + IntervalSeconds: 10, + For: 1 * time.Minute, + ExecErrState: models.ErrorErrState, + }, + evalResults: []eval.Results{ + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Normal, + EvaluatedAt: evaluationTime, + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + Error: expr.QueryError{ + RefID: "A", + Err: errors.New("this is an error"), + }, + State: eval.Error, + EvaluatedAt: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + }, + expectedAnnotations: 1, + expectedStates: map[string]*state.State{ + `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { + AlertRuleUID: "test_alert_rule_uid_2", + OrgID: 1, + CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`, + Labels: data.Labels{ + "__alert_rule_namespace_uid__": "test_namespace_uid", + "__alert_rule_uid__": "test_alert_rule_uid_2", + "alertname": "test_title", + "label": "test", + "instance_label": "test", + "datasource_uid": "datasource_uid_1", + "ref_id": "A", + }, + State: eval.Error, + Error: expr.QueryError{ + RefID: "A", + Err: errors.New("this is an error"), + }, + Results: []state.Evaluation{ + { + EvaluationTime: evaluationTime, + EvaluationState: eval.Normal, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(10 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]state.EvaluationValue), + }, + }, + StartsAt: evaluationTime.Add(10 * time.Second), + EndsAt: evaluationTime.Add(10 * time.Second).Add(state.ResendDelay * 3), + LastEvaluationTime: evaluationTime.Add(10 * time.Second), + EvaluationDuration: evaluationDuration, + Annotations: map[string]string{"annotation": "test", "Error": "failed to execute query A: this is an error"}, + }, + }, + }, + { + desc: "normal -> alerting -> error -> alerting - it should clear the error", + alertRule: &models.AlertRule{ + OrgID: 1, + Title: "test_title", + UID: "test_alert_rule_uid_2", + NamespaceUID: "test_namespace_uid", + Annotations: map[string]string{"annotation": "test"}, + Labels: map[string]string{"label": "test"}, + IntervalSeconds: 10, + For: 30 * time.Second, + }, + evalResults: []eval.Results{ + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Normal, + EvaluatedAt: evaluationTime, + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Alerting, + EvaluatedAt: evaluationTime.Add(30 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Error, + Error: fmt.Errorf("Failed to query data"), + EvaluatedAt: evaluationTime.Add(40 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Alerting, + EvaluatedAt: evaluationTime.Add(70 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + }, + expectedAnnotations: 2, + expectedStates: map[string]*state.State{ + `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { + AlertRuleUID: "test_alert_rule_uid_2", + OrgID: 1, + CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`, + Labels: data.Labels{ + "__alert_rule_namespace_uid__": "test_namespace_uid", + "__alert_rule_uid__": "test_alert_rule_uid_2", + "alertname": "test_title", + "label": "test", + "instance_label": "test", + }, + State: eval.Alerting, + Results: []state.Evaluation{ + { + EvaluationTime: evaluationTime, + EvaluationState: eval.Normal, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(30 * time.Second), + EvaluationState: eval.Alerting, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(40 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(70 * time.Second), + EvaluationState: eval.Alerting, + Values: make(map[string]state.EvaluationValue), + }, + }, + StartsAt: evaluationTime.Add(70 * time.Second), + EndsAt: evaluationTime.Add(70 * time.Second).Add(state.ResendDelay * 3), + LastEvaluationTime: evaluationTime.Add(70 * time.Second), + EvaluationDuration: evaluationDuration, + Annotations: map[string]string{"annotation": "test"}, + }, + }, + }, + { + desc: "normal -> alerting -> error -> no data - it should clear the error", + alertRule: &models.AlertRule{ + OrgID: 1, + Title: "test_title", + UID: "test_alert_rule_uid_2", + NamespaceUID: "test_namespace_uid", + Annotations: map[string]string{"annotation": "test"}, + Labels: map[string]string{"label": "test"}, + IntervalSeconds: 10, + For: 30 * time.Second, + NoDataState: models.NoData, + }, + evalResults: []eval.Results{ + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Normal, + EvaluatedAt: evaluationTime, + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Alerting, + EvaluatedAt: evaluationTime.Add(30 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.Error, + Error: fmt.Errorf("Failed to query data"), + EvaluatedAt: evaluationTime.Add(40 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + { + eval.Result{ + Instance: data.Labels{"instance_label": "test"}, + State: eval.NoData, + EvaluatedAt: evaluationTime.Add(50 * time.Second), + EvaluationDuration: evaluationDuration, + }, + }, + }, + expectedAnnotations: 2, + expectedStates: map[string]*state.State{ + `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`: { + AlertRuleUID: "test_alert_rule_uid_2", + OrgID: 1, + CacheId: `[["__alert_rule_namespace_uid__","test_namespace_uid"],["__alert_rule_uid__","test_alert_rule_uid_2"],["alertname","test_title"],["instance_label","test"],["label","test"]]`, + Labels: data.Labels{ + "__alert_rule_namespace_uid__": "test_namespace_uid", + "__alert_rule_uid__": "test_alert_rule_uid_2", + "alertname": "test_title", + "label": "test", + "instance_label": "test", + }, + State: eval.NoData, + Results: []state.Evaluation{ + { + EvaluationTime: evaluationTime, + EvaluationState: eval.Normal, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(30 * time.Second), + EvaluationState: eval.Alerting, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(40 * time.Second), + EvaluationState: eval.Error, + Values: make(map[string]state.EvaluationValue), + }, + { + EvaluationTime: evaluationTime.Add(50 * time.Second), + EvaluationState: eval.NoData, + Values: make(map[string]state.EvaluationValue), + }, + }, + StartsAt: evaluationTime.Add(30 * time.Second), + EndsAt: evaluationTime.Add(50 * time.Second).Add(state.ResendDelay * 3), + LastEvaluationTime: evaluationTime.Add(50 * time.Second), + EvaluationDuration: evaluationDuration, + Annotations: map[string]string{"annotation": "test"}, + }, + }, + }, { desc: "template is correctly expanded", alertRule: &models.AlertRule{ @@ -1230,6 +1512,9 @@ func TestProcessEvalResults(t *testing.T) { for _, tc := range testCases { st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil, &schedule.FakeInstanceStore{}) t.Run(tc.desc, func(t *testing.T) { + fakeAnnoRepo := schedule.NewFakeAnnotationsRepo() + annotations.SetRepository(fakeAnnoRepo) + for _, res := range tc.evalResults { _ = st.ProcessEvalResults(context.Background(), tc.alertRule, res) } @@ -1242,6 +1527,10 @@ func TestProcessEvalResults(t *testing.T) { require.NoError(t, err) assert.Equal(t, s, cachedState) } + + require.Eventuallyf(t, func() bool { + return tc.expectedAnnotations == fakeAnnoRepo.Len() + }, time.Second, 100*time.Millisecond, "only %d annotations are present", fakeAnnoRepo.Len()) }) } } diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 6fa9f93d7800d..ea516c1ab419d 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -1,10 +1,12 @@ package state import ( + "errors" "time" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -55,15 +57,18 @@ func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]Evalua } func (a *State) resultNormal(alertRule *ngModels.AlertRule, result eval.Result) { + a.Error = result.Error // should be nil since state is not error + if a.State != eval.Normal { a.EndsAt = result.EvaluatedAt a.StartsAt = result.EvaluatedAt } - a.Error = result.Error // should be nil since state is not error a.State = eval.Normal } func (a *State) resultAlerting(alertRule *ngModels.AlertRule, result eval.Result) { + a.Error = result.Error // should be nil since the state is not an error + switch a.State { case eval.Alerting: a.setEndsAt(alertRule, result) @@ -87,6 +92,7 @@ func (a *State) resultAlerting(alertRule *ngModels.AlertRule, result eval.Result func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) { a.Error = result.Error + if a.StartsAt.IsZero() { a.StartsAt = result.EvaluatedAt } @@ -94,10 +100,30 @@ func (a *State) resultError(alertRule *ngModels.AlertRule, result eval.Result) { if alertRule.ExecErrState == ngModels.AlertingErrState { a.State = eval.Alerting + } else if alertRule.ExecErrState == ngModels.ErrorErrState { + a.State = eval.Error + + // If the evaluation failed because a query returned an error then + // update the state with the Datasource UID as a label and the error + // message as an annotation so other code can use this metadata to + // add context to alerts + var queryError expr.QueryError + if errors.As(a.Error, &queryError) { + for _, next := range alertRule.Data { + if next.RefID == queryError.RefID { + a.Labels["ref_id"] = next.RefID + a.Labels["datasource_uid"] = next.DatasourceUID + break + } + } + a.Annotations["Error"] = queryError.Error() + } } } func (a *State) resultNoData(alertRule *ngModels.AlertRule, result eval.Result) { + a.Error = result.Error + if a.StartsAt.IsZero() { a.StartsAt = result.EvaluatedAt } @@ -114,7 +140,7 @@ func (a *State) resultNoData(alertRule *ngModels.AlertRule, result eval.Result) } func (a *State) NeedsSending(resendDelay time.Duration) bool { - if a.State == eval.Pending || a.State == eval.Error || a.State == eval.Normal && !a.Resolved { + if a.State == eval.Pending || a.State == eval.Normal && !a.Resolved { return false } // if LastSentAt is before or equal to LastEvaluationTime + resendDelay, send again diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index a7d27ca7d5b0a..b5a3ee1e54db0 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -122,7 +122,7 @@ func TestNeedsSending(t *testing.T) { }, { name: "state: error, needs to be re-sent", - expected: false, + expected: true, resendDelay: 1 * time.Minute, testState: &State{ State: eval.Error, diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index b4f36b58fb711..7d70fba042bd2 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -31,7 +31,8 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * cfg := setting.NewCfg() cfg.AlertingBaseInterval = baseInterval // AlertNG database migrations run and the relative database tables are created only when it's enabled - cfg.UnifiedAlerting.Enabled = true + cfg.UnifiedAlerting.Enabled = new(bool) + *cfg.UnifiedAlerting.Enabled = true m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := sqlstore.InitTestDB(t) diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index cd9652110a44a..243c2f726ab9d 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -2,17 +2,17 @@ package datasources import ( "context" - "crypto/sha256" "fmt" "io/ioutil" "os" "path/filepath" "strings" + "gopkg.in/yaml.v2" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/provisioning/utils" - "gopkg.in/yaml.v2" ) type configReader struct { @@ -36,12 +36,6 @@ func (cr *configReader) readConfig(ctx context.Context, path string) ([]*configs } if datasource != nil { - for _, ds := range datasource.Datasources { - if ds.UID == "" && ds.Name != "" { - ds.UID = safeUIDFromName(ds.Name) - } - } - datasources = append(datasources, datasource) } } @@ -99,11 +93,11 @@ func (cr *configReader) parseDatasourceConfig(path string, file os.FileInfo) (*c func (cr *configReader) validateDefaultUniqueness(ctx context.Context, datasources []*configs) error { defaultCount := map[int64]int{} for i := range datasources { - if datasources[i].Datasources == nil { - continue - } - for _, ds := range datasources[i].Datasources { + if ds == nil { + continue + } + if ds.OrgID == 0 { ds.OrgID = 1 } @@ -121,6 +115,10 @@ func (cr *configReader) validateDefaultUniqueness(ctx context.Context, datasourc } for _, ds := range datasources[i].DeleteDatasources { + if ds == nil { + continue + } + if ds.OrgID == 0 { ds.OrgID = 1 } @@ -145,10 +143,3 @@ func (cr *configReader) validateAccessAndOrgID(ctx context.Context, ds *upsertDa } return nil } - -func safeUIDFromName(name string) string { - h := sha256.New() - _, _ = h.Write([]byte(name)) - bs := h.Sum(nil) - return strings.ToUpper(fmt.Sprintf("P%x", bs[:8])) -} diff --git a/pkg/services/provisioning/datasources/config_reader_test.go b/pkg/services/provisioning/datasources/config_reader_test.go index 23bd909bf691a..769440e25f998 100644 --- a/pkg/services/provisioning/datasources/config_reader_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" ) @@ -17,6 +18,7 @@ var ( twoDatasourcesConfig = "testdata/two-datasources" twoDatasourcesConfigPurgeOthers = "testdata/insert-two-delete-two" + deleteOneDatasource = "testdata/delete-one" doubleDatasourcesConfig = "testdata/double-default" allProperties = "testdata/all-properties" versionZero = "testdata/version-0" @@ -39,19 +41,40 @@ func TestDatasourceAsConfig(t *testing.T) { bus.AddHandler("test", mockGetOrg) } - t.Run("apply default values when missing", func(t *testing.T) { - setup() - dc := newDatasourceProvisioner(logger) - err := dc.applyChanges(context.Background(), withoutDefaults) - if err != nil { - t.Fatalf("applyChanges return an error %v", err) - } + t.Run("when some values missing", func(t *testing.T) { + t.Run("should apply default on insert", func(t *testing.T) { + setup() + dc := newDatasourceProvisioner(logger) + err := dc.applyChanges(context.Background(), withoutDefaults) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + + require.Equal(t, len(fakeRepo.inserted), 1) + require.Equal(t, fakeRepo.inserted[0].OrgId, int64(1)) + require.Equal(t, fakeRepo.inserted[0].Access, models.DsAccess("proxy")) + require.Equal(t, fakeRepo.inserted[0].Name, "My datasource name") + require.Equal(t, fakeRepo.inserted[0].Uid, "P2AD1F727255C56BA") + }) + + t.Run("should not change UID when updates", func(t *testing.T) { + setup() + + fakeRepo.loadAll = []*models.DataSource{ + {Name: "My datasource name", OrgId: 1, Id: 1, Uid: util.GenerateShortUID()}, + } + + dc := newDatasourceProvisioner(logger) + err := dc.applyChanges(context.Background(), withoutDefaults) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } - require.Equal(t, len(fakeRepo.inserted), 1) - require.Equal(t, fakeRepo.inserted[0].OrgId, int64(1)) - require.Equal(t, fakeRepo.inserted[0].Access, models.DsAccess("proxy")) - require.Equal(t, fakeRepo.inserted[0].Name, "My datasource name") - require.Equal(t, fakeRepo.inserted[0].Uid, "P2AD1F727255C56BA") + require.Equal(t, len(fakeRepo.deleted), 0) + require.Equal(t, len(fakeRepo.inserted), 0) + require.Equal(t, len(fakeRepo.updated), 1) + require.Equal(t, "", fakeRepo.updated[0].Uid) // XORM will not update the field if its value is default + }) }) t.Run("no datasource in database", func(t *testing.T) { @@ -107,6 +130,27 @@ func TestDatasourceAsConfig(t *testing.T) { }) }) + t.Run("Remove one datasource", func(t *testing.T) { + setup() + t.Run("Remove one datasource", func(t *testing.T) { + fakeRepo.loadAll = []*models.DataSource{} + + t.Run("should have removed old datasource", func(t *testing.T) { + dc := newDatasourceProvisioner(logger) + err := dc.applyChanges(context.Background(), deleteOneDatasource) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + + require.Equal(t, 1, len(fakeRepo.deleted)) + // should have set OrgID to 1 + require.Equal(t, fakeRepo.deleted[0].OrgID, int64(1)) + require.Equal(t, 0, len(fakeRepo.inserted)) + require.Equal(t, len(fakeRepo.updated), 0) + }) + }) + }) + t.Run("Two configured datasource and purge others ", func(t *testing.T) { setup() t.Run("two other datasources in database", func(t *testing.T) { @@ -222,14 +266,6 @@ func TestDatasourceAsConfig(t *testing.T) { }) } -func TestUIDFromNames(t *testing.T) { - t.Run("generate safe uid from name", func(t *testing.T) { - require.Equal(t, safeUIDFromName("Hello world"), "P64EC88CA00B268E5") - require.Equal(t, safeUIDFromName("Hello World"), "PA591A6D40BF42040") - require.Equal(t, safeUIDFromName("AAA"), "PCB1AD2119D8FAFB6") - }) -} - func validateDeleteDatasources(t *testing.T, dsCfg *configs) { require.Equal(t, len(dsCfg.DeleteDatasources), 1) deleteDs := dsCfg.DeleteDatasources[0] diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 3e28980778d64..11e06a5421f5c 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -51,14 +51,14 @@ func (dc *DatasourceProvisioner) apply(ctx context.Context, cfg *configs) error } if errors.Is(err, models.ErrDataSourceNotFound) { - dc.log.Info("inserting datasource from configuration ", "name", ds.Name, "uid", ds.UID) insertCmd := createInsertCommand(ds) + dc.log.Info("inserting datasource from configuration ", "name", insertCmd.Name, "uid", insertCmd.Uid) if err := bus.DispatchCtx(ctx, insertCmd); err != nil { return err } } else { - dc.log.Debug("updating datasource from configuration", "name", ds.Name, "uid", ds.UID) updateCmd := createUpdateCommand(ds, cmd.Result.Id) + dc.log.Debug("updating datasource from configuration", "name", updateCmd.Name, "uid", updateCmd.Uid) if err := bus.DispatchCtx(ctx, updateCmd); err != nil { return err } diff --git a/pkg/services/provisioning/datasources/testdata/delete-one/one-datasource.yaml b/pkg/services/provisioning/datasources/testdata/delete-one/one-datasource.yaml new file mode 100644 index 0000000000000..3ffb62d4e6c84 --- /dev/null +++ b/pkg/services/provisioning/datasources/testdata/delete-one/one-datasource.yaml @@ -0,0 +1,3 @@ +datasources: [] +delete_datasources: + - name: old-data-source diff --git a/pkg/services/provisioning/datasources/types.go b/pkg/services/provisioning/datasources/types.go index cc89c9e930858..b52b9cad3afbf 100644 --- a/pkg/services/provisioning/datasources/types.go +++ b/pkg/services/provisioning/datasources/types.go @@ -1,6 +1,10 @@ package datasources import ( + "crypto/sha256" + "fmt" + "strings" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -220,7 +224,7 @@ func createInsertCommand(ds *upsertDataSourceFromConfig) *models.AddDataSourceCo } } - return &models.AddDataSourceCommand{ + cmd := &models.AddDataSourceCommand{ OrgId: ds.OrgID, Name: ds.Name, Type: ds.Type, @@ -239,6 +243,18 @@ func createInsertCommand(ds *upsertDataSourceFromConfig) *models.AddDataSourceCo ReadOnly: !ds.Editable, Uid: ds.UID, } + + if cmd.Uid == "" { + cmd.Uid = safeUIDFromName(cmd.Name) + } + return cmd +} + +func safeUIDFromName(name string) string { + h := sha256.New() + _, _ = h.Write([]byte(name)) + bs := h.Sum(nil) + return strings.ToUpper(fmt.Sprintf("P%x", bs[:8])) } func createUpdateCommand(ds *upsertDataSourceFromConfig, id int64) *models.UpdateDataSourceCommand { diff --git a/pkg/services/provisioning/datasources/types_test.go b/pkg/services/provisioning/datasources/types_test.go new file mode 100644 index 0000000000000..75820d6b12890 --- /dev/null +++ b/pkg/services/provisioning/datasources/types_test.go @@ -0,0 +1,15 @@ +package datasources + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUIDFromNames(t *testing.T) { + t.Run("generate safe uid from name", func(t *testing.T) { + require.Equal(t, safeUIDFromName("Hello world"), "P64EC88CA00B268E5") + require.Equal(t, safeUIDFromName("Hello World"), "PA591A6D40BF42040") + require.Equal(t, safeUIDFromName("AAA"), "PCB1AD2119D8FAFB6") + }) +} diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index abd0304e50018..e517abde80db8 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -62,7 +62,7 @@ func (qs *QuotaService) QuotaReached(c *models.ReqContext, target string) (bool, } continue } - query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target, UnifiedAlertingEnabled: qs.Cfg.UnifiedAlerting.Enabled} + query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target, UnifiedAlertingEnabled: qs.Cfg.UnifiedAlerting.IsEnabled()} if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { return true, err } @@ -77,7 +77,7 @@ func (qs *QuotaService) QuotaReached(c *models.ReqContext, target string) (bool, OrgId: c.OrgId, Target: scope.Target, Default: scope.DefaultLimit, - UnifiedAlertingEnabled: qs.Cfg.UnifiedAlerting.Enabled, + UnifiedAlertingEnabled: qs.Cfg.UnifiedAlerting.IsEnabled(), } if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { return true, err @@ -96,7 +96,7 @@ func (qs *QuotaService) QuotaReached(c *models.ReqContext, target string) (bool, if !c.IsSignedIn || c.UserId == 0 { continue } - query := models.GetUserQuotaByTargetQuery{UserId: c.UserId, Target: scope.Target, Default: scope.DefaultLimit, UnifiedAlertingEnabled: qs.Cfg.UnifiedAlerting.Enabled} + query := models.GetUserQuotaByTargetQuery{UserId: c.UserId, Target: scope.Target, Default: scope.DefaultLimit, UnifiedAlertingEnabled: qs.Cfg.UnifiedAlerting.IsEnabled()} if err := bus.DispatchCtx(c.Req.Context(), &query); err != nil { return true, err } diff --git a/pkg/services/secrets/database/database.go b/pkg/services/secrets/database/database.go index e088a6fd9b766..b9e0ac81863fb 100644 --- a/pkg/services/secrets/database/database.go +++ b/pkg/services/secrets/database/database.go @@ -13,15 +13,15 @@ import ( const dataKeysTable = "data_keys" -var logger = log.New("secrets-store") - type SecretsStoreImpl struct { sqlStore *sqlstore.SQLStore + log log.Logger } func ProvideSecretsStore(sqlStore *sqlstore.SQLStore) *SecretsStoreImpl { return &SecretsStoreImpl{ sqlStore: sqlStore, + log: log.New("secrets.store"), } } @@ -42,7 +42,7 @@ func (ss *SecretsStoreImpl) GetDataKey(ctx context.Context, name string) (*secre } if err != nil { - logger.Error("Failed getting data key", "err", err, "name", name) + ss.log.Error("Failed to get data key", "err", err, "name", name) return nil, fmt.Errorf("failed getting data key: %w", err) } diff --git a/pkg/services/secrets/manager/helpers.go b/pkg/services/secrets/manager/helpers.go index ff752e069c7d8..0419370d60e3c 100644 --- a/pkg/services/secrets/manager/helpers.go +++ b/pkg/services/secrets/manager/helpers.go @@ -3,6 +3,7 @@ package manager import ( "testing" + "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/secrets" @@ -24,7 +25,6 @@ func SetupTestService(tb testing.TB, store secrets.Store) *SecretsService { require.NoError(tb, err) cfg := &setting.Cfg{Raw: raw} cfg.FeatureToggles = map[string]bool{secrets.EnvelopeEncryptionFeatureToggle: true} - settings := &setting.OSSImpl{Cfg: cfg} assert.True(tb, settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle)) @@ -34,6 +34,7 @@ func SetupTestService(tb testing.TB, store secrets.Store) *SecretsService { osskmsproviders.ProvideService(encryption, settings), encryption, settings, + &usagestats.UsageStatsMock{T: tb}, ) require.NoError(tb, err) diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index 44e8705e3874c..3263dcccfcf2b 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -9,6 +9,8 @@ import ( "fmt" "time" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/kmsproviders" "github.com/grafana/grafana/pkg/services/secrets" @@ -17,13 +19,15 @@ import ( ) type SecretsService struct { - store secrets.Store - enc encryption.Internal - settings setting.Provider + store secrets.Store + enc encryption.Internal + settings setting.Provider + usageStats usagestats.Service currentProvider string providers map[string]secrets.Provider dataKeyCache map[string]dataKeyCacheItem + log log.Logger } func ProvideSecretsService( @@ -31,26 +35,55 @@ func ProvideSecretsService( kmsProvidersService kmsproviders.Service, enc encryption.Internal, settings setting.Provider, + usageStats usagestats.Service, ) (*SecretsService, error) { providers, err := kmsProvidersService.Provide() if err != nil { return nil, err } + logger := log.New("secrets") + enabled := settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) currentProvider := settings.KeyValue("security", "encryption_provider").MustString(kmsproviders.Default) + if _, ok := providers[currentProvider]; enabled && !ok { + return nil, fmt.Errorf("missing configuration for current encryption provider %s", currentProvider) + } + + if !enabled && currentProvider != kmsproviders.Default { + logger.Warn("Changing encryption provider requires enabling envelope encryption feature") + } + + logger.Debug("Envelope encryption state", "enabled", enabled, "current provider", currentProvider) + s := &SecretsService{ store: store, enc: enc, settings: settings, + usageStats: usageStats, providers: providers, currentProvider: currentProvider, dataKeyCache: make(map[string]dataKeyCacheItem), + log: logger, } + s.registerUsageMetrics() + return s, nil } +func (s *SecretsService) registerUsageMetrics() { + s.usageStats.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { + enabled := 0 + if s.settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { + enabled = 1 + } + return map[string]interface{}{ + "stats.encryption.envelope_encryption_enabled.count": enabled, + }, nil + }) +} + type dataKeyCacheItem struct { expiry time.Time dataKey []byte @@ -70,7 +103,7 @@ func (s *SecretsService) EncryptWithDBSession(ctx context.Context, payload []byt // If encryption secrets.EnvelopeEncryptionFeatureToggle toggle is on, use envelope encryption scope := opt() - keyName := fmt.Sprintf("%s/%s@%s", time.Now().Format("2006-01-02"), scope, s.currentProvider) + keyName := s.keyName(scope) dataKey, err := s.dataKey(ctx, keyName) if err != nil { @@ -101,6 +134,10 @@ func (s *SecretsService) EncryptWithDBSession(ctx context.Context, payload []byt return blob, nil } +func (s *SecretsService) keyName(scope string) string { + return fmt.Sprintf("%s/%s@%s", now().Format("2006-01-02"), scope, s.currentProvider) +} + func (s *SecretsService) Decrypt(ctx context.Context, payload []byte) ([]byte, error) { // Use legacy encryption service if secrets.EnvelopeEncryptionFeatureToggle toggle is off if !s.settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { @@ -133,6 +170,7 @@ func (s *SecretsService) Decrypt(ctx context.Context, payload []byte) ([]byte, e dataKey, err = s.dataKey(ctx, string(key)) if err != nil { + s.log.Error("Failed to lookup data key", "name", string(key), "error", err) return nil, err } } @@ -231,7 +269,7 @@ func (s *SecretsService) newDataKey(ctx context.Context, name string, scope stri // 4. Cache its unencrypted value and return it s.dataKeyCache[name] = dataKeyCacheItem{ - expiry: time.Now().Add(15 * time.Minute), + expiry: now().Add(dekTTL), dataKey: dataKey, } @@ -241,11 +279,9 @@ func (s *SecretsService) newDataKey(ctx context.Context, name string, scope stri // dataKey looks up DEK in cache or database, and decrypts it func (s *SecretsService) dataKey(ctx context.Context, name string) ([]byte, error) { if item, exists := s.dataKeyCache[name]; exists { - if item.expiry.Before(time.Now()) && !item.expiry.IsZero() { - delete(s.dataKeyCache, name) - } else { - return item.dataKey, nil - } + item.expiry = now().Add(dekTTL) + s.dataKeyCache[name] = item + return item.dataKey, nil } // 1. get encrypted data key from database @@ -267,21 +303,47 @@ func (s *SecretsService) dataKey(ctx context.Context, name string) ([]byte, erro // 3. cache data key s.dataKeyCache[name] = dataKeyCacheItem{ - expiry: time.Now().Add(15 * time.Minute), + expiry: now().Add(dekTTL), dataKey: decrypted, } return decrypted, nil } -func (s *SecretsService) RegisterProvider(providerID string, provider secrets.Provider) { - s.providers[providerID] = provider +func (s *SecretsService) GetProviders() map[string]secrets.Provider { + return s.providers } -func (s *SecretsService) CurrentProviderID() string { - return s.currentProvider +// These variables are used to test the code +// responsible for periodically cleaning up +// data encryption keys cache. +var ( + now = time.Now + dekTTL = 15 * time.Minute + gcInterval = time.Minute +) + +func (s *SecretsService) Run(ctx context.Context) error { + gc := time.NewTicker(gcInterval) + + for { + select { + case <-gc.C: + s.log.Debug("removing expired data encryption keys from cache...") + s.removeExpiredItems() + s.log.Debug("done removing expired data encryption keys from cache") + case <-ctx.Done(): + s.log.Debug("grafana is shutting down; stopping...") + gc.Stop() + return nil + } + } } -func (s *SecretsService) GetProviders() map[string]secrets.Provider { - return s.providers +func (s *SecretsService) removeExpiredItems() { + for id, dek := range s.dataKeyCache { + if dek.expiry.Before(now()) { + delete(s.dataKeyCache, id) + } + } } diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index 598097c638ae0..33dd401eb9b2d 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -3,14 +3,15 @@ package manager import ( "context" "testing" + "time" + "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" @@ -35,6 +36,7 @@ func TestSecretsService_EnvelopeEncryption(t *testing.T) { require.NoError(t, err) assert.Equal(t, len(keys), 1) }) + t.Run("encrypting another secret with no entity_id should use the same DEK", func(t *testing.T) { plaintext := []byte("another very secret string") @@ -49,6 +51,7 @@ func TestSecretsService_EnvelopeEncryption(t *testing.T) { require.NoError(t, err) assert.Equal(t, len(keys), 1) }) + t.Run("encrypting with entity_id provided should create a new DEK", func(t *testing.T) { plaintext := []byte("some test data") @@ -78,6 +81,13 @@ func TestSecretsService_EnvelopeEncryption(t *testing.T) { require.NoError(t, err) assert.Equal(t, expected, string(decrypted)) }) + + t.Run("usage stats should be registered", func(t *testing.T) { + reports, err := svc.usageStats.GetUsageReport(context.Background()) + require.NoError(t, err) + + assert.Equal(t, 1, reports.Metrics["stats.encryption.envelope_encryption_enabled.count"]) + }) } func TestSecretsService_DataKeys(t *testing.T) { @@ -152,76 +162,58 @@ func TestSecretsService_UseCurrentProvider(t *testing.T) { assert.Equal(t, "secretKey", svc.currentProvider) }) - t.Run("When encryption_provider value is set, should use it as a current provider", func(t *testing.T) { - rawCfg := `[security] - secret_key = sdDkslslld - encryption_provider = awskms.second_key` - - raw, err := ini.Load([]byte(rawCfg)) - require.NoError(t, err) - - cfg := &setting.Cfg{Raw: raw, FeatureToggles: map[string]bool{secrets.EnvelopeEncryptionFeatureToggle: true}} - settings := &setting.OSSImpl{Cfg: cfg} - - encr := ossencryption.ProvideService() - svc, err := ProvideSecretsService( - database.ProvideSecretsStore(sqlstore.InitTestDB(t)), - osskmsproviders.ProvideService(encr, settings), - encr, - settings, - ) - require.NoError(t, err) - - assert.Equal(t, "awskms.second_key", svc.currentProvider) - }) - - t.Run("Should use encrypt/decrypt methods of the current provider", func(t *testing.T) { + t.Run("Should use encrypt/decrypt methods of the current encryption provider", func(t *testing.T) { rawCfg := ` [security] secret_key = sdDkslslld - encryption_provider = fake-provider.some-key + encryption_provider = fakeProvider.v1 + available_encryption_providers = fakeProvider.v1 - [security.encryption.fake-provider.some-key] + [security.encryption.fakeProvider.v1] ` raw, err := ini.Load([]byte(rawCfg)) require.NoError(t, err) - cfg := &setting.Cfg{Raw: raw, FeatureToggles: map[string]bool{secrets.EnvelopeEncryptionFeatureToggle: true}} - settings := &setting.OSSImpl{Cfg: cfg} - + providerID := "fakeProvider.v1" + settings := &setting.OSSImpl{ + Cfg: &setting.Cfg{ + Raw: raw, + FeatureToggles: map[string]bool{secrets.EnvelopeEncryptionFeatureToggle: true}, + }, + } + encr := ossencryption.ProvideService() + kms := newFakeKMS(osskmsproviders.ProvideService(encr, settings)) secretStore := database.ProvideSecretsStore(sqlstore.InitTestDB(t)) - fake := fakeProvider{} - providerID := "fake-provider.some-key" - encr := ossencryption.ProvideService() svcEncrypt, err := ProvideSecretsService( secretStore, - osskmsproviders.ProvideService(encr, settings), + &kms, encr, settings, + &usagestats.UsageStatsMock{T: t}, ) require.NoError(t, err) - svcEncrypt.RegisterProvider(providerID, &fake) - require.NoError(t, err) - assert.Equal(t, providerID, svcEncrypt.CurrentProviderID()) + assert.Equal(t, providerID, svcEncrypt.currentProvider) assert.Equal(t, 2, len(svcEncrypt.GetProviders())) + encrypted, _ := svcEncrypt.Encrypt(context.Background(), []byte{}, secrets.WithoutScope()) - assert.True(t, fake.encryptCalled) + assert.True(t, kms.fake.encryptCalled) // secret service tries to find a DEK in a cache first before calling provider's decrypt // to bypass the cache, we set up one more secrets service to test decrypting svcDecrypt, err := ProvideSecretsService( secretStore, - osskmsproviders.ProvideService(encr, settings), + &kms, encr, settings, + &usagestats.UsageStatsMock{T: t}, ) require.NoError(t, err) - svcDecrypt.RegisterProvider(providerID, &fake) + _, _ = svcDecrypt.Decrypt(context.Background(), encrypted) - assert.True(t, fake.decryptCalled, "fake provider's decrypt should be called") + assert.True(t, kms.fake.decryptCalled, "fake provider's decrypt should be called") }) } @@ -239,3 +231,85 @@ func (p *fakeProvider) Decrypt(_ context.Context, _ []byte) ([]byte, error) { p.decryptCalled = true return []byte{}, nil } + +type fakeKMS struct { + kms osskmsproviders.Service + fake *fakeProvider +} + +func newFakeKMS(kms osskmsproviders.Service) fakeKMS { + return fakeKMS{ + kms: kms, + fake: &fakeProvider{}, + } +} + +func (f *fakeKMS) Provide() (map[string]secrets.Provider, error) { + providers, err := f.kms.Provide() + if err != nil { + return providers, err + } + + providers["fakeProvider.v1"] = f.fake + return providers, nil +} + +func TestSecretsService_Run(t *testing.T) { + ctx := context.Background() + sql := sqlstore.InitTestDB(t) + store := database.ProvideSecretsStore(sql) + svc := SetupTestService(t, store) + + t.Run("should stop with no error once the context's finished", func(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, time.Millisecond) + defer cancel() + + err := svc.Run(ctx) + assert.NoError(t, err) + }) + + t.Run("should trigger cache clean up", func(t *testing.T) { + // Encrypt to ensure there's a data encryption key generated + _, err := svc.Encrypt(ctx, []byte("grafana"), secrets.WithoutScope()) + require.NoError(t, err) + + // Data encryption key cache should contain one element + require.Len(t, svc.dataKeyCache, 1) + + // Execute background process after key's TTL, to force + // clean up process, during a hundred milliseconds with + // gc ticker configured on every nanosecond, to ensure + // the ticker is triggered. + gcInterval = time.Nanosecond + + t.Cleanup(func() { now = time.Now }) + now = func() time.Time { return time.Now().Add(dekTTL) } + + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + + err = svc.Run(ctx) + require.NoError(t, err) + + // Then, once the ticker has been triggered, + // the cleanup process should have happened, + // therefore the cache should be empty. + require.Len(t, svc.dataKeyCache, 0) + }) + + t.Run("should update data key expiry after every use", func(t *testing.T) { + // Encrypt to generate data encryption key + withoutScope := secrets.WithoutScope() + _, err := svc.Encrypt(ctx, []byte("grafana"), withoutScope) + require.NoError(t, err) + + // New call to Encrypt one minute later should update cache entry's expiry + t.Cleanup(func() { now = time.Now }) + now = func() time.Time { return time.Now().Add(time.Minute) } + _, err = svc.Encrypt(ctx, []byte("grafana"), withoutScope) + require.NoError(t, err) + + dataKeyID := svc.keyName(withoutScope()) + assert.True(t, svc.dataKeyCache[dataKeyID].expiry.After(time.Now().Add(dekTTL))) + }) +} diff --git a/pkg/services/serviceaccounts/manager/roles.go b/pkg/services/serviceaccounts/manager/roles.go index ffc4eb4ed5334..11f8b23d1cdd8 100644 --- a/pkg/services/serviceaccounts/manager/roles.go +++ b/pkg/services/serviceaccounts/manager/roles.go @@ -8,11 +8,19 @@ import ( var ( role = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ - Version: 2, + Version: 3, Name: "fixed:serviceaccounts:writer", - Description: "", + DisplayName: "Service accounts writer", + Description: "Create, delete, read, or query service accounts.", Group: "Service accounts", Permissions: []accesscontrol.Permission{ + { + Action: serviceaccounts.ActionRead, + Scope: serviceaccounts.ScopeAll, + }, + { + Action: serviceaccounts.ActionCreate, + }, { Action: serviceaccounts.ActionDelete, Scope: serviceaccounts.ScopeAll, diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index a7e41de2cb80d..c2ec6a0977b09 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -42,10 +42,10 @@ func ProvideServiceAccountsService( return s, nil } -func (s *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error { - if !s.cfg.FeatureToggles["service-accounts"] { - s.log.Debug(ServiceAccountFeatureToggleNotFound) +func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error { + if !sa.cfg.FeatureToggles["service-accounts"] { + sa.log.Debug(ServiceAccountFeatureToggleNotFound) return nil } - return s.store.DeleteServiceAccount(ctx, orgID, serviceAccountID) + return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID) } diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 6a77cd712108e..6a5070a2e384a 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -8,5 +8,8 @@ var ( ) const ( + ActionRead = "serviceaccounts:read" + ActionWrite = "serviceaccounts:write" + ActionCreate = "serviceaccounts:create" ActionDelete = "serviceaccounts:delete" ) diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 040cf298087a4..1680555d8bdc9 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -19,7 +19,7 @@ func (ss *SQLStore) addAlertQueryAndCommandHandlers() { bus.AddHandlerCtx("sql", ss.HandleAlertsQuery) bus.AddHandlerCtx("sql", ss.GetAlertById) bus.AddHandlerCtx("sql", ss.GetAllAlertQueryHandler) - bus.AddHandlerCtx("sql", SetAlertState) + bus.AddHandlerCtx("sql", ss.SetAlertState) bus.AddHandlerCtx("sql", ss.GetAlertStatesForDashboard) bus.AddHandlerCtx("sql", PauseAlert) bus.AddHandlerCtx("sql", PauseAllAlerts) @@ -305,8 +305,8 @@ func GetAlertsByDashboardId2(dashboardId int64, sess *DBSession) ([]*models.Aler return alerts, nil } -func SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error { - return inTransaction(func(sess *DBSession) error { +func (ss *SQLStore) SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error { + return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { alert := models.Alert{} if has, err := sess.ID(cmd.AlertId).Get(&alert); err != nil { diff --git a/pkg/services/sqlstore/alert_test.go b/pkg/services/sqlstore/alert_test.go index 350685f803b39..8fece7a975d59 100644 --- a/pkg/services/sqlstore/alert_test.go +++ b/pkg/services/sqlstore/alert_test.go @@ -83,7 +83,7 @@ func TestAlertingDataAccess(t *testing.T) { State: models.AlertStateOK, } - err := SetAlertState(context.Background(), cmd) + err := sqlStore.SetAlertState(context.Background(), cmd) require.Nil(t, err) }) @@ -100,7 +100,7 @@ func TestAlertingDataAccess(t *testing.T) { State: models.AlertStateOK, } - err = SetAlertState(context.Background(), cmd) + err = sqlStore.SetAlertState(context.Background(), cmd) require.Error(t, err) }) diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index f08e491dc6c8e..b9470980e5a2c 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -6,9 +6,10 @@ import ( "strings" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" - "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/metrics" @@ -214,18 +215,13 @@ func (ss *SQLStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboa // there is a unique constraint on org_id, folder_id, title // there are no nested folders so the parent folder id is always 0 dashboard := models.Dashboard{OrgId: orgID, FolderId: 0, Title: title} - has, err := ss.engine.Get(&dashboard) + has, err := ss.engine.Table(&models.Dashboard{}).Where("is_folder = " + dialect.BooleanStr(true)).Where("folder_id=0").Get(&dashboard) if err != nil { return nil, err - } else if !has { - return nil, models.ErrDashboardNotFound } - - // if there is a dashboard instead of a folder with that title - if !dashboard.IsFolder { + if !has { return nil, models.ErrDashboardNotFound } - dashboard.SetId(dashboard.Id) dashboard.SetUid(dashboard.Uid) return &dashboard, nil diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/sqlstore/dashboard_folder_test.go index 09174fbeacc65..cdef363fb9569 100644 --- a/pkg/services/sqlstore/dashboard_folder_test.go +++ b/pkg/services/sqlstore/dashboard_folder_test.go @@ -7,10 +7,11 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" - "github.com/stretchr/testify/require" ) func TestDashboardFolderDataAccess(t *testing.T) { @@ -507,6 +508,23 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) }) }) + + t.Run("Given dashboard and folder with the same title", func(t *testing.T) { + var orgId int64 = 1 + title := "Very Unique Name" + var sqlStore *SQLStore + var folder1, folder2 *models.Dashboard + sqlStore = InitTestDB(t) + folder2 = insertTestDashboard(t, sqlStore, "TEST", orgId, 0, true, "prod") + _ = insertTestDashboard(t, sqlStore, title, orgId, folder2.Id, false, "prod") + folder1 = insertTestDashboard(t, sqlStore, title, orgId, 0, true, "prod") + + t.Run("GetFolderByTitle should find the folder", func(t *testing.T) { + result, err := sqlStore.GetFolderByTitle(orgId, title) + require.NoError(t, err) + require.Equal(t, folder1.Id, result.Id) + }) + }) }) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 65b2eb13cedb9..3bbeb1c463e27 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -1,6 +1,8 @@ package migrations import ( + "os" + "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -46,6 +48,11 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { addUserAuthTokenMigrations(mg) addCacheMigration(mg) addShortURLMigrations(mg) + // TODO Delete when unified alerting is enabled by default unconditionally (Grafana v9) + if err := ualert.CheckUnifiedAlertingEnabledByDefault(mg); err != nil { // this should always go before any other ualert migration + mg.Logger.Error("failed to determine the status of alerting engine. Enable either legacy or unified alerting explicitly and try again", "err", err) + os.Exit(1) + } ualert.AddTablesMigrations(mg) ualert.AddDashAlertMigration(mg) addLibraryElementsMigrations(mg) diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 70fe6190be437..90c7918333b36 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -1,13 +1,16 @@ package migrations import ( + "fmt" + "strings" "testing" + "github.com/stretchr/testify/require" + "xorm.io/xorm" + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" - "xorm.io/xorm" ) func TestMigrations(t *testing.T) { @@ -27,7 +30,7 @@ func TestMigrations(t *testing.T) { mg := NewMigrator(x, &setting.Cfg{}) migrations := &OSSMigrations{} migrations.AddMigration(mg) - expectedMigrations := mg.MigrationsCount() + expectedMigrations := mg.GetMigrationIDs(true) err = mg.Start() require.NoError(t, err) @@ -36,7 +39,7 @@ func TestMigrations(t *testing.T) { require.NoError(t, err) require.True(t, has) - require.Equal(t, expectedMigrations, result.Count) + checkStepsAndDatabaseMatch(t, mg, expectedMigrations) mg = NewMigrator(x, &setting.Cfg{}) migrations.AddMigration(mg) @@ -47,5 +50,44 @@ func TestMigrations(t *testing.T) { has, err = x.SQL(query).Get(&result) require.NoError(t, err) require.True(t, has) - require.Equal(t, expectedMigrations, result.Count) + checkStepsAndDatabaseMatch(t, mg, expectedMigrations) +} + +func checkStepsAndDatabaseMatch(t *testing.T, mg *Migrator, expected []string) { + t.Helper() + log, err := mg.GetMigrationLog() + require.NoError(t, err) + missing := make([]string, 0) + for _, id := range expected { + _, ok := log[id] + if !ok { + missing = append(missing, id) + } + } + notIntended := make([]string, 0) + for logId := range log { + found := false + for _, s := range expected { + found = s == logId + if found { + break + } + } + if !found { + notIntended = append(notIntended, logId) + } + } + + if len(missing) == 0 && len(notIntended) == 0 { + return + } + + var msg string + if len(missing) > 0 { + msg = fmt.Sprintf("was not executed [%v], ", strings.Join(missing, ", ")) + } + if len(notIntended) > 0 { + msg += fmt.Sprintf("executed but should not [%v]", strings.Join(notIntended, ", ")) + } + require.Failf(t, "the number of migrations does not match log in database", msg) } diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule.go b/pkg/services/sqlstore/migrations/ualert/alert_rule.go index c33b3a2be2ae8..85f1fc187443f 100644 --- a/pkg/services/sqlstore/migrations/ualert/alert_rule.go +++ b/pkg/services/sqlstore/migrations/ualert/alert_rule.go @@ -5,7 +5,9 @@ import ( "fmt" "time" + "github.com/grafana/grafana/pkg/expr" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tsdb/graphite" "github.com/grafana/grafana/pkg/util" ) @@ -95,13 +97,19 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string lbls, annotations := addMigrationInfo(&da) lbls["alertname"] = da.Name annotations["message"] = da.Message + var err error + + data, err := migrateAlertRuleQueries(cond.Data) + if err != nil { + return nil, fmt.Errorf("failed to migrate alert rule queries: %w", err) + } ar := &alertRule{ OrgID: da.OrgId, Title: da.Name, // TODO: Make sure all names are unique, make new name on constraint insert error. UID: util.GenerateShortUID(), Condition: cond.Condition, - Data: cond.Data, + Data: data, IntervalSeconds: ruleAdjustInterval(da.Frequency), Version: 1, NamespaceUID: folderUID, // Folder already created, comes from env var. @@ -112,7 +120,6 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string Labels: lbls, } - var err error ar.NoDataState, err = transNoData(da.ParsedSettings.NoDataState) if err != nil { return nil, err @@ -131,6 +138,10 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string m.mg.Logger.Error("alert migration error: failed to create silence", "rule_name", ar.Title, "err", err) } + if err := m.addErrorSilence(da, ar); err != nil { + m.mg.Logger.Error("alert migration error: failed to create silence for Error", "rule_name", ar.Title, "err", err) + } + if err := m.addNoDataSilence(da, ar); err != nil { m.mg.Logger.Error("alert migration error: failed to create silence for NoData", "rule_name", ar.Title, "err", err) } @@ -138,6 +149,43 @@ func (m *migration) makeAlertRule(cond condition, da dashAlert, folderUID string return ar, nil } +// migrateAlertRuleQueries attempts to fix alert rule queries so they can work in unified alerting. Queries of some data sources are not compatible with unified alerting. +func migrateAlertRuleQueries(data []alertQuery) ([]alertQuery, error) { + result := make([]alertQuery, 0, len(data)) + for _, d := range data { + // queries that are expression are not relevant, skip them. + if d.DatasourceUID == expr.OldDatasourceUID { + result = append(result, d) + continue + } + var fixedData map[string]json.RawMessage + err := json.Unmarshal(d.Model, &fixedData) + if err != nil { + return nil, err + } + fixedData = fixGraphiteReferencedSubQueries(fixedData) + updatedModel, err := json.Marshal(fixedData) + if err != nil { + return nil, err + } + d.Model = updatedModel + result = append(result, d) + } + return result, nil +} + +// fixGraphiteReferencedSubQueries attempts to fix graphite referenced sub queries, given unified alerting does not support this. +// targetFull of Graphite data source contains the expanded version of field 'target', so let's copy that. +func fixGraphiteReferencedSubQueries(queryData map[string]json.RawMessage) map[string]json.RawMessage { + fullQuery, ok := queryData[graphite.TargetFullModelField] + if ok { + delete(queryData, graphite.TargetFullModelField) + queryData[graphite.TargetModelField] = fullQuery + } + + return queryData +} + type alertQuery struct { // RefID is the unique identifier of the query, set by the frontend call. RefID string `json:"refId"` @@ -215,7 +263,11 @@ func transExecErr(s string) (string, error) { case "", "alerting": return "Alerting", nil case "keep_state": - return "Alerting", nil + // Keep last state is translated to error as we now emit a + // DatasourceError alert when the state is error + return "Error", nil + case "ok": + return "OK", nil } return "", fmt.Errorf("unrecognized Execution Error setting %v", s) } diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule_test.go b/pkg/services/sqlstore/migrations/ualert/alert_rule_test.go new file mode 100644 index 0000000000000..533dad039fa46 --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/alert_rule_test.go @@ -0,0 +1,47 @@ +package ualert + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + + "github.com/stretchr/testify/require" +) + +func TestMigrateAlertRuleQueries(t *testing.T) { + tc := []struct { + name string + input *simplejson.Json + expected string + err error + }{ + { + name: "when a query has a sub query - it is extracted", + input: simplejson.NewFromAny(map[string]interface{}{"targetFull": "thisisafullquery", "target": "ahalfquery"}), + expected: `{"target":"thisisafullquery"}`, + }, + { + name: "when a query does not have a sub query - it no-ops", + input: simplejson.NewFromAny(map[string]interface{}{"target": "ahalfquery"}), + expected: `{"target":"ahalfquery"}`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + model, err := tt.input.Encode() + require.NoError(t, err) + queries, err := migrateAlertRuleQueries([]alertQuery{{Model: model}}) + if tt.err != nil { + require.Error(t, err) + require.EqualError(t, err, tt.err.Error()) + return + } + + require.NoError(t, err) + r, err := queries[0].Model.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, tt.expected, string(r)) + }) + } +} diff --git a/pkg/services/sqlstore/migrations/ualert/silences.go b/pkg/services/sqlstore/migrations/ualert/silences.go index 648183d1c2497..02bd997ae65b3 100644 --- a/pkg/services/sqlstore/migrations/ualert/silences.go +++ b/pkg/services/sqlstore/migrations/ualert/silences.go @@ -22,6 +22,8 @@ import ( const ( // Should be the same as 'NoDataAlertName' in pkg/services/schedule/compat.go. NoDataAlertName = "DatasourceNoData" + + ErrorAlertName = "DatasourceError" ) func (m *migration) addSilence(da dashAlert, rule *alertRule) error { @@ -61,6 +63,45 @@ func (m *migration) addSilence(da dashAlert, rule *alertRule) error { return nil } +func (m *migration) addErrorSilence(da dashAlert, rule *alertRule) error { + if da.ParsedSettings.ExecutionErrorState != "keep_state" { + return nil + } + + uid, err := uuid.NewV4() + if err != nil { + return errors.New("failed to create uuid for silence") + } + + s := &pb.MeshSilence{ + Silence: &pb.Silence{ + Id: uid.String(), + Matchers: []*pb.Matcher{ + { + Type: pb.Matcher_EQUAL, + Name: model.AlertNameLabel, + Pattern: ErrorAlertName, + }, + { + Type: pb.Matcher_EQUAL, + Name: "rule_uid", + Pattern: rule.UID, + }, + }, + StartsAt: time.Now(), + EndsAt: time.Now().AddDate(1, 0, 0), // 1 year + CreatedBy: "Grafana Migration", + Comment: fmt.Sprintf("Created during migration to unified alerting to silence Error state for alert rule ID '%s' and Title '%s' because the option 'Keep Last State' was selected for Error state", rule.UID, rule.Title), + }, + ExpiresAt: time.Now().AddDate(1, 0, 0), // 1 year + } + if _, ok := m.silences[da.OrgId]; !ok { + m.silences[da.OrgId] = make([]*pb.MeshSilence, 0) + } + m.silences[da.OrgId] = append(m.silences[da.OrgId], s) + return nil +} + func (m *migration) addNoDataSilence(da dashAlert, rule *alertRule) error { if da.ParsedSettings.NoDataState != "keep_state" { return nil diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index 7941d3d321078..4114f84b48048 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -57,7 +57,7 @@ func AddDashAlertMigration(mg *migrator.Migrator) { _, migrationRun := logs[migTitle] switch { - case mg.Cfg.UnifiedAlerting.Enabled && !migrationRun: + case mg.Cfg.UnifiedAlerting.IsEnabled() && !migrationRun: // Remove the migration entry that removes all unified alerting data. This is so when the feature // flag is removed in future the "remove unified alerting data" migration will be run again. mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, rmMigTitle), &clearMigrationEntry{ @@ -72,7 +72,7 @@ func AddDashAlertMigration(mg *migrator.Migrator) { portedChannelGroupsPerOrg: make(map[int64]map[string]string), silences: make(map[int64][]*pb.MeshSilence), }) - case !mg.Cfg.UnifiedAlerting.Enabled && migrationRun: + case !mg.Cfg.UnifiedAlerting.IsEnabled() && migrationRun: // Remove the migration entry that creates unified alerting data. This is so when the feature // flag is enabled in the future the migration "move dashboard alerts to unified alerting" will be run again. mg.AddMigration(fmt.Sprintf(clearMigrationEntryTitle, migTitle), &clearMigrationEntry{ @@ -97,7 +97,7 @@ func RerunDashAlertMigration(mg *migrator.Migrator) { cloneMigTitle := fmt.Sprintf("clone %s", migTitle) _, migrationRun := logs[cloneMigTitle] - ngEnabled := mg.Cfg.UnifiedAlerting.Enabled + ngEnabled := mg.Cfg.UnifiedAlerting.IsEnabled() switch { case ngEnabled && !migrationRun: @@ -117,7 +117,7 @@ func AddDashboardUIDPanelIDMigration(mg *migrator.Migrator) { migrationID := "update dashboard_uid and panel_id from existing annotations" _, migrationRun := logs[migrationID] - ngEnabled := mg.Cfg.UnifiedAlerting.Enabled + ngEnabled := mg.Cfg.UnifiedAlerting.IsEnabled() undoMigrationID := "undo " + migrationID if ngEnabled && !migrationRun { @@ -738,3 +738,45 @@ func (u *upgradeNgAlerting) updateAlertmanagerFiles(orgId int64, migrator *migra func (u *upgradeNgAlerting) SQL(migrator.Dialect) string { return "code migration" } + +// CheckUnifiedAlertingEnabledByDefault determines the final status of unified alerting, if it is not enabled explicitly. +// Checks table `alert` and if it is empty, then it changes UnifiedAlerting.Enabled to true. Otherwise, it sets the flag to false. +// After this method is executed the status of alerting should be determined, i.e. both flags will not be nil. +// Note: this is not a real migration but a step that other migrations depend on. +// TODO Delete when unified alerting is enabled by default unconditionally (Grafana v9) +func CheckUnifiedAlertingEnabledByDefault(migrator *migrator.Migrator) error { + // if [unified_alerting][enabled] is explicitly set, we've got nothing to do here. + if migrator.Cfg.UnifiedAlerting.Enabled != nil { + return nil + } + var ualertEnabled bool + // this duplicates the logic in setting.ReadUnifiedAlertingSettings, and is put here just for logical completeness. + if setting.AlertingEnabled != nil && !*setting.AlertingEnabled { + ualertEnabled = true + migrator.Cfg.UnifiedAlerting.Enabled = &ualertEnabled + migrator.Logger.Debug("Unified alerting is enabled because the legacy is disabled explicitly") + return nil + } + + resp := &struct { + Count int64 + }{} + exist, err := migrator.DBEngine.IsTableExist("alert") + if err != nil { + return fmt.Errorf("failed to verify if the 'alert' table exists: %w", err) + } + if exist { + if _, err := migrator.DBEngine.SQL("SELECT COUNT(1) as count FROM alert").Get(resp); err != nil { + return fmt.Errorf("failed to read 'alert' table: %w", err) + } + } + // if table does not exist then we treat it as absence of legacy alerting and therefore enable unified alerting. + + ualertEnabled = resp.Count == 0 + legacyEnabled := !ualertEnabled + migrator.Cfg.UnifiedAlerting.Enabled = &ualertEnabled + setting.AlertingEnabled = &legacyEnabled + + migrator.Logger.Debug(fmt.Sprintf("Found %d legacy alerts in the database. Unified alerting enabled is %v", resp.Count, ualertEnabled)) + return nil +} diff --git a/pkg/services/sqlstore/migrations/ualert/ualert_test.go b/pkg/services/sqlstore/migrations/ualert/ualert_test.go index 7d6ddbc1ef99c..24a0632faea4a 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert_test.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert_test.go @@ -4,7 +4,12 @@ import ( "fmt" "testing" + "xorm.io/xorm" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" @@ -73,6 +78,99 @@ func Test_validateAlertmanagerConfig(t *testing.T) { } } +func TestCheckUnifiedAlertingEnabledByDefault(t *testing.T) { + testDB := sqlutil.SQLite3TestDB() + x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) + require.NoError(t, err) + _, err = x.Exec("CREATE TABLE alert ( id bigint )") + require.NoError(t, err) + t.Cleanup(func() { + _, err = x.Exec("DROP TABLE alert") + require.NoError(t, err) + }) + + tests := []struct { + title string + legacyAlertExists bool + legacyIsDefined bool + legacyValue bool + expectedUnifiedAlerting bool + }{ + { + title: "enable unified alerting when there are no legacy alerts", + legacyIsDefined: false, + legacyAlertExists: false, + expectedUnifiedAlerting: true, + }, + { + title: "enable unified alerting when there are no legacy alerts and legacy enabled", + legacyIsDefined: true, + legacyValue: true, + legacyAlertExists: false, + expectedUnifiedAlerting: true, + }, + { + title: "enable unified alerting when there are no legacy alerts and legacy disabled", + legacyIsDefined: true, + legacyValue: false, + legacyAlertExists: false, + expectedUnifiedAlerting: true, + }, + { + title: "enable unified alerting when there are legacy alerts but legacy disabled", + legacyIsDefined: true, + legacyValue: false, + legacyAlertExists: true, + expectedUnifiedAlerting: true, + }, + { + title: "disable unified alerting when there are legacy alerts", + legacyIsDefined: false, + legacyAlertExists: true, + expectedUnifiedAlerting: false, + }, + { + title: "disable unified alerting when there are legacy alerts and it is enabled", + legacyIsDefined: true, + legacyValue: true, + legacyAlertExists: true, + expectedUnifiedAlerting: false, + }, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + setting.AlertingEnabled = nil + if test.legacyIsDefined { + value := test.legacyValue + setting.AlertingEnabled = &value + } + + if test.legacyAlertExists { + _, err := x.Exec("INSERT INTO alert VALUES (1)") + require.NoError(t, err) + } else { + _, err := x.Exec("DELETE FROM alert") + require.NoError(t, err) + } + + cfg := setting.Cfg{ + UnifiedAlerting: setting.UnifiedAlertingSettings{ + Enabled: nil, + }, + } + mg := migrator.NewMigrator(x, &cfg) + + err := CheckUnifiedAlertingEnabledByDefault(mg) + require.NoError(t, err) + require.NotNil(t, setting.AlertingEnabled) + require.NotNil(t, cfg.UnifiedAlerting.Enabled) + require.Equal(t, *cfg.UnifiedAlerting.Enabled, test.expectedUnifiedAlerting) + require.Equal(t, *setting.AlertingEnabled, !test.expectedUnifiedAlerting) + }) + } +} + func configFromReceivers(t *testing.T, receivers []*PostableGrafanaReceiver) *PostableUserConfig { t.Helper() diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index ed36505ea7187..50c4ce347fa96 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -5,16 +5,17 @@ import ( "time" _ "github.com/go-sql-driver/mysql" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util/errutil" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" ) type Migrator struct { - x *xorm.Engine + DBEngine *xorm.Engine Dialect Dialect migrations []Migration Logger log.Logger @@ -32,10 +33,10 @@ type MigrationLog struct { func NewMigrator(engine *xorm.Engine, cfg *setting.Cfg) *Migrator { mg := &Migrator{} - mg.x = engine + mg.DBEngine = engine mg.Logger = log.New("migrator") mg.migrations = make([]Migration, 0) - mg.Dialect = NewDialect(mg.x) + mg.Dialect = NewDialect(mg.DBEngine) mg.Cfg = cfg return mg } @@ -49,11 +50,22 @@ func (mg *Migrator) AddMigration(id string, m Migration) { mg.migrations = append(mg.migrations, m) } +func (mg *Migrator) GetMigrationIDs(excludeNotLogged bool) []string { + result := make([]string, 0, len(mg.migrations)) + for _, migration := range mg.migrations { + if migration.SkipMigrationLog() && excludeNotLogged { + continue + } + result = append(result, migration.Id()) + } + return result +} + func (mg *Migrator) GetMigrationLog() (map[string]MigrationLog, error) { logMap := make(map[string]MigrationLog) logItems := make([]MigrationLog, 0) - exists, err := mg.x.IsTableExist(new(MigrationLog)) + exists, err := mg.DBEngine.IsTableExist(new(MigrationLog)) if err != nil { return nil, errutil.Wrap("failed to check table existence", err) } @@ -61,7 +73,7 @@ func (mg *Migrator) GetMigrationLog() (map[string]MigrationLog, error) { return logMap, nil } - if err = mg.x.Find(&logItems); err != nil { + if err = mg.DBEngine.Find(&logItems); err != nil { return nil, err } @@ -132,7 +144,7 @@ func (mg *Migrator) Start() error { mg.Logger.Info("migrations completed", "performed", migrationsPerformed, "skipped", migrationsSkipped, "duration", time.Since(start)) // Make sure migrations are synced - return mg.x.Sync2() + return mg.DBEngine.Sync2() } func (mg *Migrator) exec(m Migration, sess *xorm.Session) error { @@ -178,7 +190,7 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error { type dbTransactionFunc func(sess *xorm.Session) error func (mg *Migrator) InTransaction(callback dbTransactionFunc) error { - sess := mg.x.NewSession() + sess := mg.DBEngine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 376fad7be684d..bce5d2352eea3 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -141,11 +141,15 @@ func TestAccountDataAccess(t *testing.T) { ac1cmd := models.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"} ac2cmd := models.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name", IsAdmin: true} + serviceaccountcmd := models.CreateUserCommand{Login: "serviceaccount", Email: "service@test.com", Name: "serviceaccount name", IsAdmin: true, IsServiceAccount: true} ac1, err := sqlStore.CreateUser(context.Background(), ac1cmd) require.NoError(t, err) ac2, err := sqlStore.CreateUser(context.Background(), ac2cmd) require.NoError(t, err) + // user only used for making sure we filter out the service accounts + _, err = sqlStore.CreateUser(context.Background(), serviceaccountcmd) + require.NoError(t, err) t.Run("Should be able to read user info projection", func(t *testing.T) { query := models.GetUserProfileQuery{UserId: ac1.Id} diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index adadc20025c3f..e825667f5f537 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -107,6 +107,10 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu whereConditions = append(whereConditions, "org_user.org_id = ?") whereParams = append(whereParams, query.OrgId) + // TODO: add to chore, for cleaning up after we have created + // service accounts table in the modelling + whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = false", x.Dialect().Quote("user"))) + if query.Query != "" { queryWithWildcards := "%" + query.Query + "%" whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)") @@ -157,6 +161,10 @@ func (ss *SQLStore) SearchOrgUsers(ctx context.Context, query *models.SearchOrgU whereConditions = append(whereConditions, "org_user.org_id = ?") whereParams = append(whereParams, query.OrgID) + // TODO: add to chore, for cleaning up after we have created + // service accounts table in the modelling + whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = false", x.Dialect().Quote("user"))) + if query.Query != "" { queryWithWildcards := "%" + query.Query + "%" whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)") @@ -189,7 +197,8 @@ func (ss *SQLStore) SearchOrgUsers(ctx context.Context, query *models.SearchOrgU // get total count orgUser := models.OrgUser{} - countSess := x.Table("org_user") + countSess := x.Table("org_user"). + Join("INNER", x.Dialect().Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) if len(whereConditions) > 0 { countSess.Where(strings.Join(whereConditions, " AND "), whereParams...) diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index f9d505d479536..b18ae742ad9e1 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -93,6 +93,7 @@ func GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) erro sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("alert_rule") + `) AS alert_rules,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("api_key") + `) AS api_keys,`) sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_panels,`, models.PanelElement) sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_variables,`, models.VariableElement) diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index 5e44dba06e1b6..a3ad79815df64 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -27,6 +27,7 @@ func TestStatsDataAccess(t *testing.T) { assert.Equal(t, int64(3), query.Result.Admins) assert.Equal(t, int64(0), query.Result.LibraryPanels) assert.Equal(t, int64(0), query.Result.LibraryVariables) + assert.Equal(t, int64(1), query.Result.APIKeys) }) t.Run("Get system user count stats should not results in error", func(t *testing.T) { @@ -125,4 +126,9 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { // force renewal of user stats err = updateUserRoleCountsIfNecessary(context.Background(), true) require.NoError(t, err) + + // add 1st api key + addAPIKeyCmd := &models.AddApiKeyCommand{OrgId: org.Id, Name: "Test key 1", Key: "secret-key", Role: models.ROLE_VIEWER} + err = sqlStore.AddAPIKey(context.Background(), addAPIKeyCmd) + require.NoError(t, err) } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index cc299cd1b16be..a2e983102a9e9 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -52,18 +52,6 @@ func getTeamMemberCount(filteredUsers []string) string { return "(SELECT COUNT(*) FROM team_member WHERE team_member.team_id = team.id) AS member_count " } -func getTeamSearchSQLBase(filteredUsers []string) string { - return `SELECT - team.id AS id, - team.org_id, - team.name AS name, - team.email AS email, - team_member.permission, ` + - getTeamMemberCount(filteredUsers) + - ` FROM team AS team - INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ? ` -} - func getTeamSelectSQLBase(filteredUsers []string) string { return `SELECT team.id as id, @@ -182,17 +170,15 @@ func SearchTeams(ctx context.Context, query *models.SearchTeamsQuery) error { params := make([]interface{}, 0) filteredUsers := getFilteredUsers(query.SignedInUser, query.HiddenUsers) - if query.UserIdFilter > 0 { - sql.WriteString(getTeamSearchSQLBase(filteredUsers)) - for _, user := range filteredUsers { - params = append(params, user) - } + sql.WriteString(getTeamSelectSQLBase(filteredUsers)) + + for _, user := range filteredUsers { + params = append(params, user) + } + + if query.UserIdFilter != models.FilterIgnoreUser { + sql.WriteString(` INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ?`) params = append(params, query.UserIdFilter) - } else { - sql.WriteString(getTeamSelectSQLBase(filteredUsers)) - for _, user := range filteredUsers { - params = append(params, user) - } } sql.WriteString(` WHERE team.org_id = ?`) @@ -221,6 +207,8 @@ func SearchTeams(ctx context.Context, query *models.SearchTeamsQuery) error { team := models.Team{} countSess := x.Table("team") + countSess.Where("team.org_id=?", query.OrgId) + if query.Query != "" { countSess.Where(`name `+dialect.LikeStr()+` ?`, queryWithWildcards) } @@ -229,6 +217,18 @@ func SearchTeams(ctx context.Context, query *models.SearchTeamsQuery) error { countSess.Where("name=?", query.Name) } + // If we're not retrieving all results, then only search for teams that this user has access to + if query.UserIdFilter != models.FilterIgnoreUser { + countSess. + Where(` + team.id IN ( + SELECT + team_id + FROM team_member + WHERE team_member.user_id = ? + )`, query.UserIdFilter) + } + count, err := countSess.Count(&team) query.Result.TotalCount = count @@ -245,6 +245,11 @@ func GetTeamById(ctx context.Context, query *models.GetTeamByIdQuery) error { params = append(params, user) } + if query.UserIdFilter != models.FilterIgnoreUser { + sql.WriteString(` INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ?`) + params = append(params, query.UserIdFilter) + } + sql.WriteString(` WHERE team.org_id = ? and team.id = ?`) params = append(params, query.OrgId, query.Id) diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 0cc36a5746cde..f3c5ffea65eab 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -609,6 +609,10 @@ func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { whereParams := make([]interface{}, 0) sess := x.Table("user").Alias("u") + // TODO: add to chore, for cleaning up after we have created + // service accounts table in the modelling + whereConditions = append(whereConditions, "u.is_service_account = false") + // Join with only most recent auth module joinCondition := `( SELECT id from user_auth diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index a938a08e98934..ee72b16280192 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -110,7 +110,7 @@ func (o OSSImpl) Current() SettingsBag { for _, section := range o.Cfg.Raw.Sections() { settingsCopy[section.Name()] = make(map[string]string) for _, key := range section.Keys() { - settingsCopy[section.Name()][key.Name()] = RedactedValue(key.Name(), key.Value()) + settingsCopy[section.Name()][key.Name()] = RedactedValue(EnvKey(section.Name(), key.Name()), key.Value()) } } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 4dd0c197461b8..5dc502d52779b 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -13,6 +13,7 @@ import ( "os" "path" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -20,6 +21,7 @@ import ( "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/util" @@ -153,7 +155,7 @@ var ( Quota QuotaSettings // Alerting - AlertingEnabled bool + AlertingEnabled *bool ExecuteAlerts bool AlertingRenderLimit int AlertingErrorOrTimeout string @@ -489,30 +491,70 @@ func RedactedValue(key, value string) string { "SECRET_KEY", "CERTIFICATE", "ACCOUNT_KEY", + "ENCRYPTION_KEY", + "VAULT_TOKEN", + "AWSKMS_.*_TOKEN", } { - if strings.Contains(uppercased, pattern) { + if match, err := regexp.MatchString(pattern, uppercased); match && err == nil { return RedactedPassword } } - // Sensitive URLs that might contain username and password - for _, pattern := range []string{ - "DATABASE_URL", + + for _, exception := range []string{ + "RUDDERSTACK", + "APPLICATION_INSIGHTS", + "SENTRY", } { - if strings.Contains(uppercased, pattern) { - if u, err := url.Parse(value); err == nil { - return u.Redacted() - } + if strings.Contains(uppercased, exception) { + return value } } - // Otherwise return unmodified value + + if u, err := RedactedURL(value); err == nil { + return u + } + return value } +func RedactedURL(value string) (string, error) { + // Value could be a list of URLs + chunks := util.SplitString(value) + + for i, chunk := range chunks { + var hasTmpPrefix bool + const tmpPrefix = "http://" + + if !strings.Contains(chunk, "://") { + chunk = tmpPrefix + chunk + hasTmpPrefix = true + } + + u, err := url.Parse(chunk) + if err != nil { + return "", err + } + + redacted := u.Redacted() + if hasTmpPrefix { + redacted = strings.Replace(redacted, tmpPrefix, "", 1) + } + + chunks[i] = redacted + } + + if strings.Contains(value, ",") { + return strings.Join(chunks, ","), nil + } + + return strings.Join(chunks, " "), nil +} + func applyEnvVariableOverrides(file *ini.File) error { appliedEnvOverrides = make([]string, 0) for _, section := range file.Sections() { for _, key := range section.Keys() { - envKey := envKey(section.Name(), key.Name()) + envKey := EnvKey(section.Name(), key.Name()) envValue := os.Getenv(envKey) if len(envValue) > 0 { @@ -583,7 +625,7 @@ type AnnotationCleanupSettings struct { MaxCount int64 } -func envKey(sectionName string, keyName string) string { +func EnvKey(sectionName string, keyName string) string { sN := strings.ToUpper(strings.ReplaceAll(sectionName, ".", "_")) sN = strings.ReplaceAll(sN, "-", "_") kN := strings.ToUpper(strings.ReplaceAll(keyName, ".", "_")) @@ -914,9 +956,6 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { if err := readAlertingSettings(iniFile); err != nil { return err } - if err := cfg.ReadUnifiedAlertingSettings(iniFile); err != nil { - return err - } explore := iniFile.Section("explore") ExploreEnabled = explore.Key("enabled").MustBool(true) @@ -1109,7 +1148,7 @@ type DynamicSection struct { // Key dynamically overrides keys with environment variables. // As a side effect, the value of the setting key will be updated if an environment variable is present. func (s *DynamicSection) Key(k string) *ini.Key { - envKey := envKey(s.section.Name(), k) + envKey := EnvKey(s.section.Name(), k) envValue := os.Getenv(envKey) key := s.section.Key(k) @@ -1377,7 +1416,11 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { func readAlertingSettings(iniFile *ini.File) error { alerting := iniFile.Section("alerting") - AlertingEnabled = alerting.Key("enabled").MustBool(true) + enabled, err := alerting.Key("enabled").Bool() + AlertingEnabled = nil + if err == nil { + AlertingEnabled = &enabled + } ExecuteAlerts = alerting.Key("execute_alerts").MustBool(true) AlertingRenderLimit = alerting.Key("concurrent_render_limit").MustInt(5) diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go index 406ccf2abf10f..139189f7e84ef 100644 --- a/pkg/setting/setting_quota.go +++ b/pkg/setting/setting_quota.go @@ -68,7 +68,7 @@ func (cfg *Cfg) readQuotaSettings() { var alertOrgQuota int64 var alertGlobalQuota int64 - if cfg.UnifiedAlerting.Enabled { + if cfg.UnifiedAlerting.IsEnabled() { alertOrgQuota = quota.Key("org_alert_rule").MustInt64(100) alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1) } diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index 42d29a7230cf5..8eac5e99be5f4 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -2,6 +2,7 @@ package setting import ( "bufio" + "math/rand" "net/url" "os" "path" @@ -428,17 +429,23 @@ func TestGetCDNPathWithAlphaVersion(t *testing.T) { } func TestAlertingEnabled(t *testing.T) { + anyBoolean := func() bool { + return rand.Int63()%2 == 0 + } + testCases := []struct { desc string unifiedAlertingEnabled string legacyAlertingEnabled string featureToggleSet bool + isEnterprise bool verifyCfg func(*testing.T, Cfg, *ini.File) }{ { desc: "when legacy alerting is enabled and unified is disabled", legacyAlertingEnabled: "true", unifiedAlertingEnabled: "false", + isEnterprise: anyBoolean(), verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -446,14 +453,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, false) - assert.Equal(t, AlertingEnabled, true) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, true) }, }, { desc: "when legacy alerting is disabled and unified is enabled", legacyAlertingEnabled: "false", unifiedAlertingEnabled: "true", + isEnterprise: anyBoolean(), verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -461,14 +471,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, true) - assert.Equal(t, AlertingEnabled, false) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, false) }, }, { - desc: "when both alerting are enabled, it should error", + desc: "when both alerting are enabled", legacyAlertingEnabled: "true", unifiedAlertingEnabled: "true", + isEnterprise: anyBoolean(), verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -479,9 +492,10 @@ func TestAlertingEnabled(t *testing.T) { }, }, { - desc: "when legacy alerting is invalid and unified is disabled", - legacyAlertingEnabled: "invalid", + desc: "when legacy alerting is invalid (or not defined) and unified is disabled", + legacyAlertingEnabled: "", unifiedAlertingEnabled: "false", + isEnterprise: anyBoolean(), verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -489,42 +503,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, false) - assert.Equal(t, AlertingEnabled, true) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, true) }, }, { - desc: "when legacy alerting is invalid and unified is enabled", - legacyAlertingEnabled: "invalid", + desc: "when legacy alerting is invalid (or not defined) and unified is enabled", + legacyAlertingEnabled: "", unifiedAlertingEnabled: "true", - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.Error(t, err) - }, - }, - { - desc: "when legacy alerting is enabled and unified is invalid", - legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "invalid", - verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { - err := readAlertingSettings(f) - require.NoError(t, err) - err = cfg.readFeatureToggles(f) - require.NoError(t, err) - err = cfg.ReadUnifiedAlertingSettings(f) - require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, false) - assert.Equal(t, AlertingEnabled, true) - }, - }, - { - desc: "when legacy alerting is disabled and unified is invalid", - legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "invalid", + isEnterprise: anyBoolean(), verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -532,14 +521,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, false) - assert.Equal(t, AlertingEnabled, false) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, false) }, }, { - desc: "when both are invalid", - legacyAlertingEnabled: "invalid", - unifiedAlertingEnabled: "invalid", + desc: "when legacy alerting is enabled and unified is invalid (or not defined) [OSS]", + legacyAlertingEnabled: "true", + unifiedAlertingEnabled: "", + isEnterprise: false, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -547,15 +539,16 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, false) - assert.Equal(t, AlertingEnabled, true) + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, true) }, }, { - desc: "when legacy alerting is enabled and unified is disabled and feature toggle is set", + desc: "when legacy alerting is enabled and unified is invalid (or not defined) [Enterprise]", legacyAlertingEnabled: "true", - unifiedAlertingEnabled: "false", - featureToggleSet: true, + unifiedAlertingEnabled: "", + isEnterprise: true, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -563,15 +556,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, true) - assert.Equal(t, AlertingEnabled, false) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, true) }, }, { - desc: "when legacy alerting is disabled and unified is disabled and feature toggle is set", + desc: "when legacy alerting is disabled and unified is invalid (or not defined) [OSS]", legacyAlertingEnabled: "false", - unifiedAlertingEnabled: "false", - featureToggleSet: true, + unifiedAlertingEnabled: "invalid", + isEnterprise: false, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -579,15 +574,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, true) - assert.Equal(t, AlertingEnabled, false) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, true) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, false) }, }, { - desc: "when legacy alerting is disabled and unified is invalid and feature toggle is set", + desc: "when legacy alerting is disabled and unified is invalid (or not defined) [Enterprise]", legacyAlertingEnabled: "false", unifiedAlertingEnabled: "invalid", - featureToggleSet: true, + isEnterprise: true, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -595,15 +592,17 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, true) - assert.Equal(t, AlertingEnabled, false) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, false) }, }, { - desc: "when legacy alerting is invalid and unified is disabled and feature toggle is set", + desc: "when both are invalid (or not defined) [OSS]", legacyAlertingEnabled: "invalid", - unifiedAlertingEnabled: "false", - featureToggleSet: true, + unifiedAlertingEnabled: "invalid", + isEnterprise: false, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -611,29 +610,33 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, true) - assert.Equal(t, AlertingEnabled, false) + assert.Nil(t, cfg.UnifiedAlerting.Enabled) + assert.Nil(t, AlertingEnabled) }, }, { - desc: "when legacy alerting is invalid and unified is enabled and feature toggle is set", + desc: "when both are invalid (or not defined) [Enterprise]", legacyAlertingEnabled: "invalid", - unifiedAlertingEnabled: "true", - featureToggleSet: true, + unifiedAlertingEnabled: "invalid", + isEnterprise: true, verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) err = cfg.readFeatureToggles(f) require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) - require.Error(t, err) + require.NoError(t, err) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, true) }, }, { - desc: "when both are invalid and feature toggle is set", - legacyAlertingEnabled: "invalid", - unifiedAlertingEnabled: "invalid", - featureToggleSet: true, + desc: "when both are false", + legacyAlertingEnabled: "false", + unifiedAlertingEnabled: "false", + isEnterprise: anyBoolean(), verifyCfg: func(t *testing.T, cfg Cfg, f *ini.File) { err := readAlertingSettings(f) require.NoError(t, err) @@ -641,16 +644,24 @@ func TestAlertingEnabled(t *testing.T) { require.NoError(t, err) err = cfg.ReadUnifiedAlertingSettings(f) require.NoError(t, err) - assert.Equal(t, cfg.UnifiedAlerting.Enabled, true) - assert.Equal(t, AlertingEnabled, false) + assert.NotNil(t, cfg.UnifiedAlerting.Enabled) + assert.Equal(t, *cfg.UnifiedAlerting.Enabled, false) + assert.NotNil(t, AlertingEnabled) + assert.Equal(t, *AlertingEnabled, false) }, }, } + var isEnterpriseOld = IsEnterprise + t.Cleanup(func() { + IsEnterprise = isEnterpriseOld + }) + for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { + IsEnterprise = tc.isEnterprise t.Cleanup(func() { - AlertingEnabled = false + AlertingEnabled = nil }) f := ini.Empty() @@ -665,13 +676,6 @@ func TestAlertingEnabled(t *testing.T) { _, err = alertingSec.NewKey("enabled", tc.legacyAlertingEnabled) require.NoError(t, err) - if tc.featureToggleSet { - alertingSec, err := f.NewSection("feature_toggles") - require.NoError(t, err) - _, err = alertingSec.NewKey("enable", "ngalert") - require.NoError(t, err) - } - tc.verifyCfg(t, *cfg, f) }) } diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index a415dd8374217..d1332086ec361 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -7,6 +7,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/grafana/grafana/pkg/util" "github.com/prometheus/alertmanager/cluster" @@ -63,26 +64,68 @@ type UnifiedAlertingSettings struct { EvaluationTimeout time.Duration ExecuteAlerts bool DefaultConfiguration string - Enabled bool + Enabled *bool // determines whether unified alerting is enabled. If it is nil then user did not define it and therefore its value will be determined during migration. Services should not use it directly. DisabledOrgs map[int64]struct{} } +// IsEnabled returns true if UnifiedAlertingSettings.Enabled is either nil or true. +// It hides the implementation details of the Enabled and simplifies its usage. +func (u *UnifiedAlertingSettings) IsEnabled() bool { + return u.Enabled == nil || *u.Enabled +} + +func (cfg *Cfg) readUnifiedAlertingEnabledSetting(section *ini.Section) (*bool, error) { + enabled, err := section.Key("enabled").Bool() + // the unified alerting is not enabled by default. First, check the feature flag + if err != nil { + // TODO: Remove in Grafana v9 + if cfg.FeatureToggles["ngalert"] { + cfg.Logger.Warn("ngalert feature flag is deprecated: use unified alerting enabled setting instead") + enabled = true + // feature flag overrides the legacy alerting setting. + legacyAlerting := false + AlertingEnabled = &legacyAlerting + return &enabled, nil + } + if IsEnterprise { + enabled = false + if AlertingEnabled == nil { + legacyEnabled := true + AlertingEnabled = &legacyEnabled + } + return &enabled, nil + } + // next, check whether legacy flag is set + if AlertingEnabled != nil && !*AlertingEnabled { + enabled = true + return &enabled, nil // if legacy alerting is explicitly disabled, enable the unified alerting by default. + } + // NOTE: If the enabled flag is still not defined, the final decision is made during migration (see sqlstore.migrations.ualert.CheckUnifiedAlertingEnabledByDefault). + cfg.Logger.Info("The state of unified alerting is still not defined. The decision will be made during as we run the database migrations") + return nil, nil // the flag is not defined + } + + // If unified alerting is defined explicitly as well as legacy alerting and both are enabled, return error. + if enabled && AlertingEnabled != nil && *AlertingEnabled { + return nil, errors.New("both legacy and Grafana 8 Alerts are enabled. Disable one of them and restart") + } + // if legacy alerting is not defined but unified is determined then update the legacy with inverted value + if AlertingEnabled == nil { + legacyEnabled := !enabled + AlertingEnabled = &legacyEnabled + } + return &enabled, nil +} + // ReadUnifiedAlertingSettings reads both the `unified_alerting` and `alerting` sections of the configuration while preferring configuration the `alerting` section. // It first reads the `unified_alerting` section, then looks for non-defaults on the `alerting` section and prefers those. func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { + var err error uaCfg := UnifiedAlertingSettings{} ua := iniFile.Section("unified_alerting") - uaCfg.Enabled = ua.Key("enabled").MustBool(false) - - // TODO: Deprecate this in v8.4, if the old feature toggle ngalert is set, enable Grafana 8 Unified Alerting anyway. - if !uaCfg.Enabled && cfg.FeatureToggles["ngalert"] { - cfg.Logger.Warn("ngalert feature flag is deprecated: use unified alerting enabled setting instead") - uaCfg.Enabled = true - AlertingEnabled = false - } - - if uaCfg.Enabled && AlertingEnabled { - return errors.New("both legacy and Grafana 8 Alerts are enabled") + uaCfg.Enabled, err = cfg.readUnifiedAlertingEnabledSetting(ua) + if err != nil { + return err } uaCfg.DisabledOrgs = make(map[int64]struct{}) @@ -95,7 +138,6 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { uaCfg.DisabledOrgs[orgID] = struct{}{} } - var err error uaCfg.AdminConfigPollInterval, err = gtime.ParseDuration(valueAsString(ua, "admin_config_poll_interval", (schedulerDefaultAdminConfigPollInterval).String())) if err != nil { return err diff --git a/pkg/tests/api/alerting/api_admin_configuration_test.go b/pkg/tests/api/alerting/api_admin_configuration_test.go index 3ced6ca3aca4f..d6d5d1e3e009e 100644 --- a/pkg/tests/api/alerting/api_admin_configuration_test.go +++ b/pkg/tests/api/alerting/api_admin_configuration_test.go @@ -64,7 +64,7 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) { resp := getRequest(t, alertsURL, http.StatusNotFound) // nolint b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, string(b), "{\"message\": \"no admin configuration available\"}") + require.JSONEq(t, string(b), "{\"message\": \"API error\",\"error\": \"no admin configuration available\"}") } // Now, lets re-set external Alertmanagers for main organisation. diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index bdfab20116f8a..96628bc1baef0 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -88,7 +88,7 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { } ` resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint - require.JSONEq(t, `{"message":"failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) + require.JSONEq(t, `{"message": "API error","error":"failed to save and apply Alertmanager configuration: failed to build integration map: the receiver is invalid: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body)) @@ -208,7 +208,7 @@ func TestAlertmanagerConfigurationPersistSecrets(t *testing.T) { ` resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint - require.JSONEq(t, `{"message": "unknown receiver: invalid"}`, getBody(t, resp.Body)) + require.JSONEq(t, `{"message": "API error","error": "unknown receiver: invalid"}`, getBody(t, resp.Body)) } // The secure settings must be present diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 4629af0c466e4..da8b2f1a3cab6 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -96,7 +96,7 @@ func TestAMConfigAccess(t *testing.T) { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusForbidden, - expBody: `{"message": "permission denied"}`, + expBody: `{"message": "API error","error": "permission denied"}`, }, { desc: "editor request should succeed", @@ -166,7 +166,7 @@ func TestAMConfigAccess(t *testing.T) { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/config/api/v1/alerts", expStatus: http.StatusForbidden, - expBody: `{"message": "permission denied"}`, + expBody: `{"message": "API error","error": "permission denied"}`, }, { desc: "editor request should succeed", @@ -229,7 +229,7 @@ func TestAMConfigAccess(t *testing.T) { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/api/v2/silences", expStatus: http.StatusForbidden, - expBody: `{"message": "permission denied"}`, + expBody: `{"message": "API error","error": "permission denied"}`, }, { desc: "editor request should succeed", @@ -335,7 +335,7 @@ func TestAMConfigAccess(t *testing.T) { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/alertmanager/grafana/api/v2/silence/%s", expStatus: http.StatusForbidden, - expBody: `{"message": "permission denied"}`, + expBody: `{"message": "API error","error": "permission denied"}`, }, { desc: "editor request should succeed", @@ -603,7 +603,7 @@ func TestRulerAccess(t *testing.T) { desc: "viewer request should fail", url: "http://viewer:viewer@%s/api/ruler/grafana/api/v1/rules/default", expStatus: http.StatusForbidden, - expectedResponse: `{"message":"user does not have permissions to edit the namespace: user does not have permissions to edit the namespace"}`, + expectedResponse: `{"message": "API error","error":"user does not have permissions to edit the namespace: user does not have permissions to edit the namespace"}`, }, { desc: "editor request should succeed", @@ -890,7 +890,7 @@ func TestAlertRuleCRUD(t *testing.T) { Data: []ngmodels.AlertQuery{}, }, }, - expectedResponse: `{"message":"failed to update rule group: invalid alert rule: no queries or expressions are found"}`, + expectedResponse: `{"message": "API error","error":"failed to update rule group: invalid alert rule: no queries or expressions are found"}`, }, { desc: "alert rule with empty title", @@ -920,7 +920,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message":"failed to update rule group: invalid alert rule: title is empty"}`, + expectedResponse: `{"message": "API error","error":"failed to update rule group: invalid alert rule: title is empty"}`, }, { desc: "alert rule with too long name", @@ -950,7 +950,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message":"failed to update rule group: invalid alert rule: name length should not be greater than 190"}`, + expectedResponse: `{"message": "API error","error":"failed to update rule group: invalid alert rule: name length should not be greater than 190"}`, }, { desc: "alert rule with too long rulegroup", @@ -980,7 +980,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message":"failed to update rule group: invalid alert rule: rule group name length should not be greater than 190"}`, + expectedResponse: `{"message": "API error","error":"failed to update rule group: invalid alert rule: rule group name length should not be greater than 190"}`, }, { desc: "alert rule with invalid interval", @@ -1011,7 +1011,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message":"failed to update rule group: invalid alert rule: interval (1s) should be non-zero and divided exactly by scheduler interval: 10s"}`, + expectedResponse: `{"message": "API error","error":"failed to update rule group: invalid alert rule: interval (1s) should be non-zero and divided exactly by scheduler interval: 10s"}`, }, { desc: "alert rule with unknown datasource", @@ -1041,7 +1041,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message":"failed to validate alert rule \"AlwaysFiring\": invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "API error","error":"failed to validate alert rule \"AlwaysFiring\": invalid query A: data source not found: unknown"}`, }, { desc: "alert rule with invalid condition", @@ -1071,7 +1071,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, }, }, - expectedResponse: `{"message":"failed to validate alert rule \"AlwaysFiring\": condition B not found in any query or expression: it should be one of: [A]"}`, + expectedResponse: `{"message": "API error","error":"failed to validate alert rule \"AlwaysFiring\": condition B not found in any query or expression: it should be one of: [A]"}`, }, } @@ -1361,7 +1361,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode) - require.JSONEq(t, `{"message":"failed to update rule group: failed to get alert rule unknown: could not find alert rule"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"failed to update rule group: failed to get alert rule unknown: could not find alert rule"}`, string(b)) // let's make sure that rule definitions are not affected by the failed POST request. u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1480,7 +1480,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - require.JSONEq(t, fmt.Sprintf(`{"message":"failed to validate alert rule \"AlwaysAlerting\": conflicting UID \"%s\" found"}`, ruleUID), string(b)) + require.JSONEq(t, fmt.Sprintf(`{"message": "API error","error":"failed to validate alert rule \"AlwaysAlerting\": conflicting UID \"%s\" found"}`, ruleUID), string(b)) // let's make sure that rule definitions are not affected by the failed POST request. u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1885,7 +1885,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusNotFound, resp.StatusCode) - require.JSONEq(t, `{"message":"failed to delete rule group: rule group not found under this namespace"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"failed to delete rule group: rule group not found under this namespace"}`, string(b)) }) t.Run("succeed if the rule group name does exist", func(t *testing.T) { @@ -2093,7 +2093,7 @@ func TestQuota(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) - require.JSONEq(t, `{"message":"quota reached"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"quota reached"}`, string(b)) }) t.Run("when quota limit exceed updating existing rule should succeed", func(t *testing.T) { @@ -2388,7 +2388,7 @@ func TestEval(t *testing.T) { } `, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"message":"invalid condition: condition B not found in any query or expression: it should be one of: [A]"}`, + expectedResponse: `{"message": "API error","error":"invalid condition: condition B not found in any query or expression: it should be one of: [A]"}`, }, { desc: "unknown query datasource", @@ -2413,7 +2413,7 @@ func TestEval(t *testing.T) { } `, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"message":"invalid condition: invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "API error","error":"invalid condition: invalid query A: data source not found: unknown"}`, }, } @@ -2569,7 +2569,7 @@ func TestEval(t *testing.T) { } `, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"message":"invalid queries or expressions: invalid query A: data source not found: unknown"}`, + expectedResponse: `{"message": "API error","error":"invalid queries or expressions: invalid query A: data source not found: unknown"}`, }, } diff --git a/pkg/tests/api/alerting/api_available_channel_test.go b/pkg/tests/api/alerting/api_available_channel_test.go index fef0f0f15c82a..dfb04d582a0fe 100644 --- a/pkg/tests/api/alerting/api_available_channel_test.go +++ b/pkg/tests/api/alerting/api_available_channel_test.go @@ -1446,6 +1446,22 @@ var expAvailableChannelJsonOutput = ` "required": true, "validationRule": "", "secure": false + }, + { + "element": "textarea", + "inputType": "", + "label": "Message", + "description": "", + "placeholder": "{{ template \"default.message\" . }}", + "propertyName": "message", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false } ] }, diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 41e9dc90aa50a..6c2cfa905d2ee 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -120,7 +120,8 @@ func TestTestReceivers(t *testing.T) { expectedJSON := fmt.Sprintf(`{ "alert": { "annotations": { - "summary": "Notification test" + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "TestAlert", @@ -202,7 +203,8 @@ func TestTestReceivers(t *testing.T) { expectedJSON := fmt.Sprintf(`{ "alert": { "annotations": { - "summary": "Notification test" + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "TestAlert", @@ -292,7 +294,8 @@ func TestTestReceivers(t *testing.T) { expectedJSON := fmt.Sprintf(`{ "alert": { "annotations": { - "summary": "Notification test" + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "TestAlert", @@ -395,7 +398,8 @@ func TestTestReceivers(t *testing.T) { expectedJSON := fmt.Sprintf(`{ "alert": { "annotations": { - "summary": "Notification test" + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "TestAlert", @@ -461,7 +465,8 @@ func TestTestReceiversAlertCustomization(t *testing.T) { resp := postRequest(t, testReceiversURL, `{ "alert": { "annotations": { - "annotation1": "value1" + "annotation1": "value1", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "label1": "value1" @@ -500,7 +505,8 @@ func TestTestReceiversAlertCustomization(t *testing.T) { "alert": { "annotations": { "annotation1": "value1", - "summary": "Notification test" + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "TestAlert", @@ -556,7 +562,8 @@ func TestTestReceiversAlertCustomization(t *testing.T) { resp := postRequest(t, testReceiversURL, `{ "alert": { "annotations": { - "summary": "This is a custom annotation" + "summary": "This is a custom annotation", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" } }, "receivers": [{ @@ -591,7 +598,8 @@ func TestTestReceiversAlertCustomization(t *testing.T) { expectedJSON := fmt.Sprintf(`{ "alert": { "annotations": { - "summary": "This is a custom annotation" + "summary": "This is a custom annotation", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "TestAlert", @@ -681,7 +689,8 @@ func TestTestReceiversAlertCustomization(t *testing.T) { expectedJSON := fmt.Sprintf(`{ "alert": { "annotations": { - "summary": "Notification test" + "summary": "Notification test", + "__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]" }, "labels": { "alertname": "This is a custom label", @@ -2140,7 +2149,7 @@ var expNonEmailNotifications = map[string][]string{ { "title": "[FIRING:1] SlackAlert2 ", "title_link": "http://localhost:3000/alerting/list", - "text": "**Firing**\n\nLabels:\n - alertname = SlackAlert2\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SlackAlert2/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSlackAlert2\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SlackAlert2\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SlackAlert2/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSlackAlert2\n", "fallback": "[FIRING:1] SlackAlert2 ", "footer": "Grafana v", "footer_icon": "https://grafana.com/assets/img/fav32.png", @@ -2173,7 +2182,7 @@ var expNonEmailNotifications = map[string][]string{ "component": "Integration Test", "group": "testgroup", "custom_details": { - "firing": "\nLabels:\n - alertname = PagerdutyAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PagerdutyAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DPagerdutyAlert\n", + "firing": "\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PagerdutyAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PagerdutyAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DPagerdutyAlert\n", "num_firing": "1", "num_resolved": "0", "resolved": "" @@ -2193,7 +2202,7 @@ var expNonEmailNotifications = map[string][]string{ `{ "link": { "messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%3A3000%2Falerting%2Flist", - "text": "**Firing**\n\nLabels:\n - alertname = DingDingAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DingDingAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDingDingAlert\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DingDingAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DingDingAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDingDingAlert\n", "title": "[FIRING:1] DingDingAlert " }, "msgtype": "link" @@ -2218,7 +2227,7 @@ var expNonEmailNotifications = map[string][]string{ ], "sections": [ { - "text": "**Firing**\n\nLabels:\n - alertname = TeamsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TeamsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTeamsAlert\n", + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TeamsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TeamsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTeamsAlert\n", "title": "Details" } ], @@ -2262,12 +2271,12 @@ var expNonEmailNotifications = map[string][]string{ "truncatedAlerts": 0, "title": "[FIRING:1] WebhookAlert ", "state": "alerting", - "message": "**Firing**\n\nLabels:\n - alertname = WebhookAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_WebhookAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DWebhookAlert\n" + "message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = WebhookAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_WebhookAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DWebhookAlert\n" }`, }, "discord_recv/discord_test": { `{ - "content": "**Firing**\n\nLabels:\n - alertname = DiscordAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DiscordAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDiscordAlert\n", + "content": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = DiscordAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_DiscordAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DDiscordAlert\n", "embeds": [ { "color": 14037554, @@ -2295,7 +2304,7 @@ var expNonEmailNotifications = map[string][]string{ }, "name": "default" }, - "output": "**Firing**\n\nLabels:\n - alertname = SensuGoAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SensuGoAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSensuGoAlert\n", + "output": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = SensuGoAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_SensuGoAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DSensuGoAlert\n", "status": 2 }, "entity": { @@ -2308,10 +2317,10 @@ var expNonEmailNotifications = map[string][]string{ }`, }, "pushover_recv/pushover_test": { - "--abcd\r\nContent-Disposition: form-data; name=\"user\"\r\n\r\nmysecretkey\r\n--abcd\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nmysecrettoken\r\n--abcd\r\nContent-Disposition: form-data; name=\"priority\"\r\n\r\n0\r\n--abcd\r\nContent-Disposition: form-data; name=\"sound\"\r\n\r\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n[FIRING:1] PushoverAlert \r\n--abcd\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\nhttp://localhost:3000/alerting/list\r\n--abcd\r\nContent-Disposition: form-data; name=\"url_title\"\r\n\r\nShow alert rule\r\n--abcd\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n**Firing**\n\nLabels:\n - alertname = PushoverAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PushoverAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DPushoverAlert\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"html\"\r\n\r\n1\r\n--abcd--\r\n", + "--abcd\r\nContent-Disposition: form-data; name=\"user\"\r\n\r\nmysecretkey\r\n--abcd\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nmysecrettoken\r\n--abcd\r\nContent-Disposition: form-data; name=\"priority\"\r\n\r\n0\r\n--abcd\r\nContent-Disposition: form-data; name=\"sound\"\r\n\r\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n[FIRING:1] PushoverAlert \r\n--abcd\r\nContent-Disposition: form-data; name=\"url\"\r\n\r\nhttp://localhost:3000/alerting/list\r\n--abcd\r\nContent-Disposition: form-data; name=\"url_title\"\r\n\r\nShow alert rule\r\n--abcd\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = PushoverAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_PushoverAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DPushoverAlert\n\r\n--abcd\r\nContent-Disposition: form-data; name=\"html\"\r\n\r\n1\r\n--abcd--\r\n", }, "telegram_recv/bot6sh027hs034h": { - "--abcd\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\ntelegram_chat_id\r\n--abcd\r\nContent-Disposition: form-data; name=\"parse_mode\"\r\n\r\nhtml\r\n--abcd\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n**Firing**\n\nLabels:\n - alertname = TelegramAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TelegramAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTelegramAlert\n\r\n--abcd--\r\n", + "--abcd\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\ntelegram_chat_id\r\n--abcd\r\nContent-Disposition: form-data; name=\"parse_mode\"\r\n\r\nhtml\r\n--abcd\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = TelegramAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_TelegramAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DTelegramAlert\n\r\n--abcd--\r\n", }, "googlechat_recv/googlechat_test": { `{ @@ -2327,7 +2336,7 @@ var expNonEmailNotifications = map[string][]string{ "widgets": [ { "textParagraph": { - "text": "**Firing**\n\nLabels:\n - alertname = GoogleChatAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_GoogleChatAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DGoogleChatAlert\n" + "text": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = GoogleChatAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_GoogleChatAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DGoogleChatAlert\n" } }, { @@ -2365,7 +2374,7 @@ var expNonEmailNotifications = map[string][]string{ "client": "Grafana", "client_url": "http://localhost:3000/alerting/list", "description": "[FIRING:1] KafkaAlert ", - "details": "**Firing**\n\nLabels:\n - alertname = KafkaAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_KafkaAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DKafkaAlert\n", + "details": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = KafkaAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_KafkaAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DKafkaAlert\n", "incident_key": "35c0bdb1715f9162a20d7b2a01cb2e3a4c5b1dc663571701e3f67212b696332f" } } @@ -2373,10 +2382,10 @@ var expNonEmailNotifications = map[string][]string{ }`, }, "line_recv/line_test": { - `message=%5BFIRING%3A1%5D+LineAlert+%0Ahttp%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0ALabels%3A%0A+-+alertname+%3D+LineAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_LineAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DLineAlert%0A`, + `message=%5BFIRING%3A1%5D+LineAlert+%0Ahttp%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+LineAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_LineAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DLineAlert%0A`, }, "threema_recv/threema_test": { - `from=%2A1234567&secret=myapisecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D+ThreemaAlert+%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0ALabels%3A%0A+-+alertname+%3D+ThreemaAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_ThreemaAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DThreemaAlert%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A&to=abcdefgh`, + `from=%2A1234567&secret=myapisecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D+ThreemaAlert+%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5B+var%3D%27A%27+labels%3D%7B%7D+value%3D1+%5D%0ALabels%3A%0A+-+alertname+%3D+ThreemaAlert%0AAnnotations%3A%0ASource%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2FUID_ThreemaAlert%2Fedit%0ASilence%3A+http%3A%2F%2Flocalhost%3A3000%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matchers%3Dalertname%253DThreemaAlert%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%3A3000%2Falerting%2Flist%0A&to=abcdefgh`, }, "victorops_recv/victorops_test": { `{ @@ -2385,14 +2394,14 @@ var expNonEmailNotifications = map[string][]string{ "entity_id": "633ae988fa7074bcb51f3d1c5fef2ba1c5c4ccb45b3ecbf681f7d507b078b1ae", "message_type": "CRITICAL", "monitoring_tool": "Grafana v", - "state_message": "**Firing**\n\nLabels:\n - alertname = VictorOpsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_VictorOpsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DVictorOpsAlert\n", + "state_message": "**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = VictorOpsAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_VictorOpsAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%%3DVictorOpsAlert\n", "timestamp": %s }`, }, "opsgenie_recv/opsgenie_test": { `{ "alias": "47e92f0f6ef9fe99f3954e0d6155f8d09c4b9a038d8c3105e82c0cee4c62956e", - "description": "[FIRING:1] OpsGenieAlert \nhttp://localhost:3000/alerting/list\n\n**Firing**\n\nLabels:\n - alertname = OpsGenieAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_OpsGenieAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DOpsGenieAlert\n", + "description": "[FIRING:1] OpsGenieAlert \nhttp://localhost:3000/alerting/list\n\n**Firing**\n\nValue: [ var='A' labels={} value=1 ]\nLabels:\n - alertname = OpsGenieAlert\nAnnotations:\nSource: http://localhost:3000/alerting/UID_OpsGenieAlert/edit\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matchers=alertname%3DOpsGenieAlert\n", "details": { "url": "http://localhost:3000/alerting/list" }, diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index bcf6ae5d926c9..5633e74a8dfad 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -201,7 +201,7 @@ func TestPrometheusRules(t *testing.T) { require.NoError(t, err) assert.Equal(t, 400, resp.StatusCode) - require.JSONEq(t, `{"message":"failed to update rule group: invalid alert rule: cannot have Panel ID without a Dashboard UID"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"failed to update rule group: invalid alert rule: cannot have Panel ID without a Dashboard UID"}`, string(b)) } // Now, let's see how this looks like. @@ -593,7 +593,7 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message":"invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) } // Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response @@ -609,7 +609,7 @@ func TestPrometheusRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message":"panel_id must be set with dashboard_uid"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"panel_id must be set with dashboard_uid"}`, string(b)) } } diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 3ab3ceb4bb7d4..ac924499cf63a 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -421,7 +421,7 @@ func TestAlertRuleConflictingTitle(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - require.JSONEq(t, `{"message":"failed to update rule group: a conflicting alert rule is found: rule title under the same organisation and folder should be unique"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"failed to update rule group: a conflicting alert rule is found: rule title under the same organisation and folder should be unique"}`, string(b)) }) t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) { @@ -772,7 +772,7 @@ func TestRulerRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message":"invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) } // Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response @@ -788,6 +788,6 @@ func TestRulerRulesFilterByDashboard(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.JSONEq(t, `{"message":"panel_id must be set with dashboard_uid"}`, string(b)) + require.JSONEq(t, `{"message": "API error","error":"panel_id must be set with dashboard_uid"}`, string(b)) } } diff --git a/pkg/tsdb/cloudmonitoring/resource_handler.go b/pkg/tsdb/cloudmonitoring/resource_handler.go index fd4ab79b0feea..32141147a0866 100644 --- a/pkg/tsdb/cloudmonitoring/resource_handler.go +++ b/pkg/tsdb/cloudmonitoring/resource_handler.go @@ -23,7 +23,7 @@ var nameExp = regexp.MustCompile(`([^\/]*)\/*$`) const resourceManagerPath = "/v1/projects" -type processResponse func(body []byte) ([]byte, error) +type processResponse func(body []byte) ([]json.RawMessage, string, error) func (s *Service) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/gceDefaultProject", getGCEDefaultProject) @@ -50,35 +50,30 @@ func (s *Service) resourceHandler(subDataSource string, responseFn processRespon writeResponse(rw, code, fmt.Sprintf("unexpected error %v", err)) return } - s.doRequest(rw, req, client, responseFn) + getResources(rw, req, client, responseFn) } } -func (s *Service) doRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client, responseFn processResponse) http.ResponseWriter { - res, err := cli.Do(req) - if err != nil { - writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) - return rw - } - defer func() { - if err := res.Body.Close(); err != nil { - slog.Warn("Failed to close response body", "err", err) - } - }() - +func getResources(rw http.ResponseWriter, req *http.Request, cli *http.Client, responseFn processResponse) http.ResponseWriter { if responseFn == nil { writeResponse(rw, http.StatusInternalServerError, "responseFn should not be nil") return rw } - body, code, err := processData(res, responseFn) + responses, headers, encoding, code, err := getResponses(req, cli, responseFn) if err != nil { writeResponse(rw, code, fmt.Sprintf("unexpected error %v", err)) return rw } - writeResponseBytes(rw, res.StatusCode, body) - for k, v := range res.Header { + body, err := buildResponse(responses, encoding) + if err != nil { + writeResponse(rw, http.StatusInternalServerError, fmt.Sprintf("error formatting responose %v", err)) + return rw + } + writeResponseBytes(rw, code, body) + + for k, v := range headers { rw.Header().Set(k, v[0]) for _, v := range v[1:] { rw.Header().Add(k, v) @@ -87,97 +82,113 @@ func (s *Service) doRequest(rw http.ResponseWriter, req *http.Request, cli *http return rw } -func processMetricDescriptors(body []byte) ([]byte, error) { +func processMetricDescriptors(body []byte) ([]json.RawMessage, string, error) { resp := metricDescriptorResponse{} err := json.Unmarshal(body, &resp) if err != nil { - return nil, err + return nil, "", err } + results := []json.RawMessage{} for i := range resp.Descriptors { resp.Descriptors[i].Service = strings.SplitN(resp.Descriptors[i].Type, "/", 2)[0] resp.Descriptors[i].ServiceShortName = strings.SplitN(resp.Descriptors[i].Service, ".", 2)[0] if resp.Descriptors[i].DisplayName == "" { resp.Descriptors[i].DisplayName = resp.Descriptors[i].Type } + descriptor, err := json.Marshal(resp.Descriptors[i]) + if err != nil { + return nil, "", err + } + results = append(results, descriptor) } - return json.Marshal(resp.Descriptors) + return results, resp.Token, nil } -func processServices(body []byte) ([]byte, error) { +func processServices(body []byte) ([]json.RawMessage, string, error) { resp := serviceResponse{} err := json.Unmarshal(body, &resp) if err != nil { - return nil, err + return nil, "", err } - values := []selectableValue{} + results := []json.RawMessage{} for _, service := range resp.Services { name := nameExp.FindString(service.Name) if name == "" { - return nil, fmt.Errorf("unexpected service name: %v", service.Name) + return nil, "", fmt.Errorf("unexpected service name: %v", service.Name) } label := service.DisplayName if label == "" { label = name } - values = append(values, selectableValue{ + marshaledValue, err := json.Marshal(selectableValue{ Value: name, Label: label, }) + if err != nil { + return nil, "", err + } + results = append(results, marshaledValue) } - return json.Marshal(values) + return results, resp.Token, nil } -func processSLOs(body []byte) ([]byte, error) { +func processSLOs(body []byte) ([]json.RawMessage, string, error) { resp := sloResponse{} err := json.Unmarshal(body, &resp) if err != nil { - return nil, err + return nil, "", err } - values := []selectableValue{} + results := []json.RawMessage{} for _, slo := range resp.SLOs { name := nameExp.FindString(slo.Name) if name == "" { - return nil, fmt.Errorf("unexpected service name: %v", slo.Name) + return nil, "", fmt.Errorf("unexpected service name: %v", slo.Name) } - values = append(values, selectableValue{ + marshaledValue, err := json.Marshal(selectableValue{ Value: name, Label: slo.DisplayName, Goal: slo.Goal, }) + if err != nil { + return nil, "", err + } + results = append(results, marshaledValue) } - return json.Marshal(values) + return results, resp.Token, nil } -func processProjects(body []byte) ([]byte, error) { +func processProjects(body []byte) ([]json.RawMessage, string, error) { resp := projectResponse{} err := json.Unmarshal(body, &resp) if err != nil { - return nil, err + return nil, "", err } - values := []selectableValue{} + results := []json.RawMessage{} for _, project := range resp.Projects { - values = append(values, selectableValue{ + marshaledValue, err := json.Marshal(selectableValue{ Value: project.ProjectID, Label: project.Name, }) + if err != nil { + return nil, "", err + } + results = append(results, marshaledValue) } - return json.Marshal(values) + return results, resp.Token, nil } -func processData(res *http.Response, responseFn processResponse) ([]byte, int, error) { - encoding := res.Header.Get("Content-Encoding") - +func decode(encoding string, original io.ReadCloser) ([]byte, error) { var reader io.Reader var err error switch encoding { case "gzip": - reader, err = gzip.NewReader(res.Body) + reader, err = gzip.NewReader(original) if err != nil { - return nil, http.StatusBadRequest, fmt.Errorf("unexpected error %v", err) + return nil, err } defer func() { if err := reader.(io.ReadCloser).Close(); err != nil { @@ -185,45 +196,44 @@ func processData(res *http.Response, responseFn processResponse) ([]byte, int, e } }() case "deflate": - reader = flate.NewReader(res.Body) + reader = flate.NewReader(original) defer func() { if err := reader.(io.ReadCloser).Close(); err != nil { slog.Warn("Failed to close reader body", "err", err) } }() case "br": - reader = brotli.NewReader(res.Body) + reader = brotli.NewReader(original) case "": - reader = res.Body + reader = original default: - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected encoding type %v", err) + return nil, fmt.Errorf("unexpected encoding type %v", err) } body, err := ioutil.ReadAll(reader) if err != nil { - return nil, http.StatusBadRequest, fmt.Errorf("unexpected error %v", err) - } - - body, err = responseFn(body) - if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("data processing error %v", err) + return nil, err } + return body, nil +} +func encode(encoding string, body []byte) ([]byte, error) { buf := new(bytes.Buffer) var writer io.Writer = buf + var err error switch encoding { case "gzip": writer = gzip.NewWriter(writer) case "deflate": writer, err = flate.NewWriter(writer, -1) if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected error %v", err) + return nil, err } case "br": writer = brotli.NewWriter(writer) case "": default: - return nil, http.StatusInternalServerError, fmt.Errorf("unexpected encoding type %v", encoding) + return nil, fmt.Errorf("unexpected encoding type %v", encoding) } _, err = writer.Write(body) @@ -233,10 +243,91 @@ func processData(res *http.Response, responseFn processResponse) ([]byte, int, e } } if err != nil { - return nil, http.StatusInternalServerError, fmt.Errorf("unable to encode response %v", err) + return nil, fmt.Errorf("unable to encode response %v", err) + } + return buf.Bytes(), nil +} + +func processData(data io.ReadCloser, encoding string, responseFn processResponse) ([]json.RawMessage, string, int, error) { + body, err := decode(encoding, data) + if err != nil { + return nil, "", http.StatusBadRequest, fmt.Errorf("unable to decode response %v", err) + } + + response, token, err := responseFn(body) + if err != nil { + return nil, "", http.StatusInternalServerError, fmt.Errorf("data processing error %v", err) + } + return response, token, 0, nil +} + +type apiResponse struct { + encoding string + header http.Header + responses []json.RawMessage + token string + code int + err error +} + +func doRequest(req *http.Request, cli *http.Client, responseFn processResponse) *apiResponse { + res, err := cli.Do(req) + if err != nil { + return &apiResponse{code: http.StatusBadRequest, err: err} + } + defer func() { + if err := res.Body.Close(); err != nil { + slog.Warn("Failed to close response body", "err", err) + } + }() + encoding := res.Header.Get("Content-Encoding") + originalHeader := res.Header + code := res.StatusCode + + responses, token, errcode, err := processData(res.Body, encoding, responseFn) + if err != nil { + code = errcode + } + return &apiResponse{ + encoding: encoding, + header: originalHeader, + responses: responses, + token: token, + code: code, + err: err, + } +} + +func getResponses(req *http.Request, cli *http.Client, responseFn processResponse) ([]json.RawMessage, http.Header, string, int, error) { + result := doRequest(req, cli, responseFn) + if result.err != nil { + return nil, nil, "", result.code, result.err + } + + token := result.token + responses := result.responses + for token != "" { + query := req.URL.Query() + query.Set("pageToken", token) + req.URL.RawQuery = query.Encode() + + loopResult := doRequest(req, cli, responseFn) + if loopResult.err != nil { + return nil, nil, "", loopResult.code, loopResult.err + } + responses = append(responses, loopResult.responses...) + token = loopResult.token + } + return responses, result.header, result.encoding, result.code, nil +} + +func buildResponse(responses []json.RawMessage, encoding string) ([]byte, error) { + body, err := json.Marshal(responses) + if err != nil { + return nil, fmt.Errorf("response marshaling error %v", err) } - return buf.Bytes(), 0, nil + return encode(encoding, body) } func (s *Service) setRequestVariables(req *http.Request, subDataSource string) (*http.Client, int, error) { diff --git a/pkg/tsdb/cloudmonitoring/resource_handler_test.go b/pkg/tsdb/cloudmonitoring/resource_handler_test.go index 1a54cc123e7b6..a98674251fe53 100644 --- a/pkg/tsdb/cloudmonitoring/resource_handler_test.go +++ b/pkg/tsdb/cloudmonitoring/resource_handler_test.go @@ -43,15 +43,23 @@ func Test_parseResourcePath(t *testing.T) { } } -func fakeResponseFn(input []byte) ([]byte, error) { - return input, nil -} - func Test_doRequest(t *testing.T) { - // test that it forwards the header and body + // test that it forwards the header and body over multiple calls + elements := []string{"1", "2", "3"} + index := 0 + + fakeResponseFn := func(input []byte) ([]json.RawMessage, string, error) { + results := []json.RawMessage{input} + if index < len(elements) { + return results, "token", nil + } + return results, "", nil + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("foo", "bar") - _, err := w.Write([]byte("result")) + _, err := w.Write([]byte(elements[index])) + index++ if err != nil { t.Fatal(err) } @@ -60,10 +68,9 @@ func Test_doRequest(t *testing.T) { if err != nil { t.Error(err) } - s := Service{} rw := httptest.NewRecorder() - res := s.doRequest(rw, req, srv.Client(), fakeResponseFn) + res := getResources(rw, req, srv.Client(), fakeResponseFn) if res.Header().Get("foo") != "bar" { t.Errorf("Unexpected headers: %v", res.Header()) } @@ -76,7 +83,7 @@ func Test_doRequest(t *testing.T) { if err != nil { t.Error(err) } - if string(body) != "result" { + if string(body) != "[1,2,3]" { t.Errorf("Unexpected body: %v", string(body)) } } @@ -135,6 +142,7 @@ func Test_processData_functions(t *testing.T) { Description: "baz", }, }, + Token: "foo", } marshaledMDResponse, _ := json.Marshal(metricDescriptorResp) metricDescriptorResult := []metricDescriptor{ @@ -227,35 +235,47 @@ func Test_processData_functions(t *testing.T) { responseFn processResponse input []byte result []byte + token string }{ { "metricDescriptor", processMetricDescriptors, marshaledMDResponse, marshaledMDResult, + "foo", }, { "services", processServices, marshaledServiceResponse, marshaledServiceResult, + "", }, { "slos", processSLOs, marshaledSLOResponse, marshaledSLOResult, + "", }, { "cloudresourcemanager", processProjects, marshaledCRResponse, marshaledServiceResult, + "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - res, err := test.responseFn(test.input) + results, token, err := test.responseFn(test.input) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + if token != test.token { + t.Errorf("Unexpected token. Got %s, expecting %s", token, test.token) + } + res, err := json.Marshal(results) if err != nil { t.Errorf("Unexpected error %v", err) } diff --git a/pkg/tsdb/cloudmonitoring/types.go b/pkg/tsdb/cloudmonitoring/types.go index 1d413f1c8123f..77cb29913ac0f 100644 --- a/pkg/tsdb/cloudmonitoring/types.go +++ b/pkg/tsdb/cloudmonitoring/types.go @@ -192,6 +192,7 @@ type timeSeries struct { type metricDescriptorResponse struct { Descriptors []metricDescriptor `json:"metricDescriptors"` + Token string `json:"nextPageToken"` } type metricDescriptor struct { ValueType string `json:"valueType"` @@ -206,6 +207,7 @@ type metricDescriptor struct { type projectResponse struct { Projects []projectDescription `json:"projects"` + Token string `json:"nextPageToken"` } type projectDescription struct { @@ -215,6 +217,7 @@ type projectDescription struct { type serviceResponse struct { Services []serviceDescription `json:"services"` + Token string `json:"nextPageToken"` } type serviceDescription struct { Name string `json:"name"` @@ -222,7 +225,8 @@ type serviceDescription struct { } type sloResponse struct { - SLOs []sloDescription `json:"serviceLevelObjectives"` + SLOs []sloDescription `json:"serviceLevelObjectives"` + Token string `json:"nextPageToken"` } type sloDescription struct { diff --git a/pkg/tsdb/cloudwatch/cloudwatch_query.go b/pkg/tsdb/cloudwatch/cloudwatch_query.go index 28163e729e8c3..229e56612440d 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_query.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_query.go @@ -9,34 +9,57 @@ import ( ) type cloudWatchQuery struct { - RefId string - Region string - Id string - Namespace string - MetricName string - Statistic string - Expression string - ReturnData bool - Dimensions map[string][]string - Period int - Alias string - MatchExact bool - UsedExpression string + RefId string + Region string + Id string + Namespace string + MetricName string + Statistic string + Expression string + SqlExpression string + ReturnData bool + Dimensions map[string][]string + Period int + Alias string + MatchExact bool + UsedExpression string + MetricQueryType metricQueryType + MetricEditorMode metricEditorMode +} + +func (q *cloudWatchQuery) getGMDAPIMode() gmdApiMode { + if q.MetricQueryType == MetricQueryTypeSearch && q.MetricEditorMode == MetricEditorModeBuilder { + if q.isInferredSearchExpression() { + return GMDApiModeInferredSearchExpression + } + return GMDApiModeMetricStat + } else if q.MetricQueryType == MetricQueryTypeSearch && q.MetricEditorMode == MetricEditorModeRaw { + return GMDApiModeMathExpression + } else if q.MetricQueryType == MetricQueryTypeQuery { + return GMDApiModeSQLExpression + } + + plog.Warn("Could not resolve CloudWatch metric query type. Falling back to metric stat.", "query", q) + return GMDApiModeMetricStat } func (q *cloudWatchQuery) isMathExpression() bool { - return q.Expression != "" && !q.isUserDefinedSearchExpression() + return q.MetricQueryType == MetricQueryTypeSearch && q.MetricEditorMode == MetricEditorModeRaw && !q.isUserDefinedSearchExpression() } func (q *cloudWatchQuery) isSearchExpression() bool { - return q.isUserDefinedSearchExpression() || q.isInferredSearchExpression() + return q.MetricQueryType == MetricQueryTypeSearch && (q.isUserDefinedSearchExpression() || q.isInferredSearchExpression()) } func (q *cloudWatchQuery) isUserDefinedSearchExpression() bool { - return strings.Contains(q.Expression, "SEARCH(") + return q.MetricQueryType == MetricQueryTypeSearch && q.MetricEditorMode == MetricEditorModeRaw && strings.Contains(q.Expression, "SEARCH(") } func (q *cloudWatchQuery) isInferredSearchExpression() bool { + if q.MetricQueryType != MetricQueryTypeSearch || q.MetricEditorMode != MetricEditorModeBuilder { + return false + } + if len(q.Dimensions) == 0 { return !q.MatchExact } @@ -58,6 +81,10 @@ func (q *cloudWatchQuery) isInferredSearchExpression() bool { } func (q *cloudWatchQuery) isMultiValuedDimensionExpression() bool { + if q.MetricQueryType != MetricQueryTypeSearch || q.MetricEditorMode != MetricEditorModeBuilder { + return false + } + for _, values := range q.Dimensions { for _, v := range values { if v == "*" { @@ -74,7 +101,7 @@ func (q *cloudWatchQuery) isMultiValuedDimensionExpression() bool { } func (q *cloudWatchQuery) buildDeepLink(startTime time.Time, endTime time.Time) (string, error) { - if q.isMathExpression() { + if q.isMathExpression() || q.MetricQueryType == MetricQueryTypeQuery { return "", nil } diff --git a/pkg/tsdb/cloudwatch/cloudwatch_query_test.go b/pkg/tsdb/cloudwatch/cloudwatch_query_test.go index f75a60c23ba49..035e808fdd09a 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_query_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_query_test.go @@ -2,11 +2,36 @@ package cloudwatch import ( "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCloudWatchQuery(t *testing.T) { + t.Run("Deeplink is not generated for MetricQueryTypeQuery", func(t *testing.T) { + startTime := time.Now() + endTime := startTime.Add(2 * time.Hour) + query := &cloudWatchQuery{ + RefId: "A", + Region: "us-east-1", + Expression: "", + Statistic: "Average", + Period: 300, + Id: "id1", + MatchExact: true, + Dimensions: map[string][]string{ + "InstanceId": {"i-12345678"}, + }, + MetricQueryType: MetricQueryTypeQuery, + MetricEditorMode: MetricEditorModeBuilder, + } + + deepLink, err := query.buildDeepLink(startTime, endTime) + require.NoError(t, err) + assert.Empty(t, deepLink) + }) + t.Run("SEARCH(someexpression) was specified in the query editor", func(t *testing.T) { query := &cloudWatchQuery{ RefId: "A", @@ -107,14 +132,12 @@ func TestCloudWatchQuery(t *testing.T) { query.MatchExact = false assert.True(t, query.isSearchExpression(), "Expected a search expression") assert.False(t, query.isMathExpression(), "Expected not math expression") - assert.False(t, query.isMetricStat(), "Expected not metric stat") }) t.Run("Match exact is true", func(t *testing.T) { query.MatchExact = true assert.False(t, query.isSearchExpression(), "Exxpected not search expression") assert.False(t, query.isMathExpression(), "Expected not math expression") - assert.True(t, query.isMetricStat(), "Expected a metric stat") }) }) @@ -134,10 +157,5 @@ func TestCloudWatchQuery(t *testing.T) { assert.True(t, query.isSearchExpression(), "Expected search expression") assert.False(t, query.isMathExpression(), "Expected not math expression") - assert.False(t, query.isMetricStat(), "Expected not metric stat") }) } - -func (q *cloudWatchQuery) isMetricStat() bool { - return !q.isSearchExpression() && !q.isMathExpression() -} diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder.go b/pkg/tsdb/cloudwatch/metric_data_query_builder.go index 34c6a8b246f91..ff97deb412bd3 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder.go @@ -16,30 +16,32 @@ func (e *cloudWatchExecutor) buildMetricDataQuery(query *cloudWatchQuery) (*clou ReturnData: aws.Bool(query.ReturnData), } - if query.Expression != "" { + switch query.getGMDAPIMode() { + case GMDApiModeMathExpression: + mdq.Period = aws.Int64(int64(query.Period)) mdq.Expression = aws.String(query.Expression) + case GMDApiModeSQLExpression: mdq.Period = aws.Int64(int64(query.Period)) - } else { - if query.isSearchExpression() { - mdq.Expression = aws.String(buildSearchExpression(query, query.Statistic)) - } else { - mdq.MetricStat = &cloudwatch.MetricStat{ - Metric: &cloudwatch.Metric{ - Namespace: aws.String(query.Namespace), - MetricName: aws.String(query.MetricName), - Dimensions: make([]*cloudwatch.Dimension, 0), - }, - Period: aws.Int64(int64(query.Period)), - } - for key, values := range query.Dimensions { - mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions, - &cloudwatch.Dimension{ - Name: aws.String(key), - Value: aws.String(values[0]), - }) - } - mdq.MetricStat.Stat = aws.String(query.Statistic) + mdq.Expression = aws.String(query.SqlExpression) + case GMDApiModeInferredSearchExpression: + mdq.Expression = aws.String(buildSearchExpression(query, query.Statistic)) + case GMDApiModeMetricStat: + mdq.MetricStat = &cloudwatch.MetricStat{ + Metric: &cloudwatch.Metric{ + Namespace: aws.String(query.Namespace), + MetricName: aws.String(query.MetricName), + Dimensions: make([]*cloudwatch.Dimension, 0), + }, + Period: aws.Int64(int64(query.Period)), + } + for key, values := range query.Dimensions { + mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions, + &cloudwatch.Dimension{ + Name: aws.String(key), + Value: aws.String(values[0]), + }) } + mdq.MetricStat.Stat = aws.String(query.Statistic) } if mdq.Expression != nil { diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go index 80ffb3ec54bb2..faaca8e423543 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go @@ -7,21 +7,63 @@ import ( "github.com/stretchr/testify/require" ) -func TestMetricDataQueryBuilder_buildSearchExpression(t *testing.T) { +func TestMetricDataQueryBuilder(t *testing.T) { t.Run("buildMetricDataQuery", func(t *testing.T) { + t.Run("should use metric stat", func(t *testing.T) { + executor := newExecutor(nil, nil, newTestConfig(), fakeSessionCache{}) + query := getBaseQuery() + query.MetricEditorMode = MetricEditorModeBuilder + query.MetricQueryType = MetricQueryTypeSearch + mdq, err := executor.buildMetricDataQuery(query) + require.NoError(t, err) + require.Empty(t, mdq.Expression) + assert.Equal(t, query.MetricName, *mdq.MetricStat.Metric.MetricName) + assert.Equal(t, query.Namespace, *mdq.MetricStat.Metric.Namespace) + }) + + t.Run("should use custom built expression", func(t *testing.T) { + executor := newExecutor(nil, nil, newTestConfig(), fakeSessionCache{}) + query := getBaseQuery() + query.MetricEditorMode = MetricEditorModeBuilder + query.MetricQueryType = MetricQueryTypeSearch + query.MatchExact = false + mdq, err := executor.buildMetricDataQuery(query) + require.NoError(t, err) + require.Nil(t, mdq.MetricStat) + assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"="lb1"', '', 300))`, *mdq.Expression) + }) + + t.Run("should use sql expression", func(t *testing.T) { + executor := newExecutor(nil, nil, newTestConfig(), fakeSessionCache{}) + query := getBaseQuery() + query.MetricEditorMode = MetricEditorModeRaw + query.MetricQueryType = MetricQueryTypeQuery + query.SqlExpression = `SELECT SUM(CPUUTilization) FROM "AWS/EC2"` + mdq, err := executor.buildMetricDataQuery(query) + require.NoError(t, err) + require.Nil(t, mdq.MetricStat) + assert.Equal(t, query.SqlExpression, *mdq.Expression) + }) + + t.Run("should use user defined math expression", func(t *testing.T) { + executor := newExecutor(nil, nil, newTestConfig(), fakeSessionCache{}) + query := getBaseQuery() + query.MetricEditorMode = MetricEditorModeRaw + query.MetricQueryType = MetricQueryTypeSearch + query.Expression = `SUM(x+y)` + mdq, err := executor.buildMetricDataQuery(query) + require.NoError(t, err) + require.Nil(t, mdq.MetricStat) + assert.Equal(t, query.Expression, *mdq.Expression) + }) + t.Run("should set period in user defined expression", func(t *testing.T) { executor := newExecutor(nil, nil, newTestConfig(), fakeSessionCache{}) - query := &cloudWatchQuery{ - Namespace: "AWS/EC2", - MetricName: "CPUUtilization", - Dimensions: map[string][]string{ - "LoadBalancer": {"lb1"}, - }, - Period: 300, - Expression: "SUM([a,b])", - MatchExact: true, - } + query := getBaseQuery() + query.MetricEditorMode = MetricEditorModeRaw + query.MetricQueryType = MetricQueryTypeSearch query.MatchExact = false + query.Expression = `SUM([a,b])` mdq, err := executor.buildMetricDataQuery(query) require.NoError(t, err) require.Nil(t, mdq.MetricStat) @@ -235,3 +277,17 @@ func TestMetricDataQueryBuilder_buildSearchExpression(t *testing.T) { assert.Contains(t, res, `lb4\"\"`, "Expected escape double quotes") }) } + +func getBaseQuery() *cloudWatchQuery { + query := &cloudWatchQuery{ + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Dimensions: map[string][]string{ + "LoadBalancer": {"lb1"}, + }, + Period: 300, + Expression: "", + MatchExact: true, + } + return query +} diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index b8be2df2a5709..63ab192560cd2 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -85,7 +85,7 @@ var metricsMap = map[string][]string{ "AWS/ElasticInference": {"AcceleratorHealthCheckFailed", "AcceleratorMemoryUsage", "ConnectivityCheckFailed"}, "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"}, "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"}, - "AWS/Events": {"DeadLetterInvocations", "FailedInvocations", "Invocations", "MatchedEvents", "ThrottledRules", "TriggeredRules"}, + "AWS/Events": {"DeadLetterInvocations", "FailedInvocations", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"}, "AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"}, "AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"}, "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"}, diff --git a/pkg/tsdb/cloudwatch/request_parser.go b/pkg/tsdb/cloudwatch/request_parser.go index 411d69e172a9a..c0202c9738351 100644 --- a/pkg/tsdb/cloudwatch/request_parser.go +++ b/pkg/tsdb/cloudwatch/request_parser.go @@ -143,6 +143,7 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time id = fmt.Sprintf("query%s", refId) } expression := model.Get("expression").MustString("") + sqlExpression := model.Get("sqlExpression").MustString("") alias := model.Get("alias").MustString() returnData := !model.Get("hide").MustBool(false) queryType := model.Get("type").MustString() @@ -154,21 +155,34 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time } matchExact := model.Get("matchExact").MustBool(true) + metricQueryType := metricQueryType(model.Get("metricQueryType").MustInt(0)) + + var metricEditorModeValue metricEditorMode + memv, err := model.Get("metricEditorMode").Int() + if err != nil && len(expression) > 0 { + // this should only ever happen if this is an alerting query that has not yet been migrated in the frontend + metricEditorModeValue = MetricEditorModeRaw + } else { + metricEditorModeValue = metricEditorMode(memv) + } return &cloudWatchQuery{ - RefId: refId, - Region: region, - Id: id, - Namespace: namespace, - MetricName: metricName, - Statistic: statistic, - Expression: expression, - ReturnData: returnData, - Dimensions: dimensions, - Period: period, - Alias: alias, - MatchExact: matchExact, - UsedExpression: "", + RefId: refId, + Region: region, + Id: id, + Namespace: namespace, + MetricName: metricName, + Statistic: statistic, + Expression: expression, + ReturnData: returnData, + Dimensions: dimensions, + Period: period, + Alias: alias, + MatchExact: matchExact, + UsedExpression: "", + MetricQueryType: metricQueryType, + MetricEditorMode: metricEditorModeValue, + SqlExpression: sqlExpression, }, nil } diff --git a/pkg/tsdb/cloudwatch/request_parser_test.go b/pkg/tsdb/cloudwatch/request_parser_test.go index 3d49eb9c8bb06..4538f9770aeff 100644 --- a/pkg/tsdb/cloudwatch/request_parser_test.go +++ b/pkg/tsdb/cloudwatch/request_parser_test.go @@ -272,4 +272,54 @@ func TestRequestParser(t *testing.T) { assert.Equal(t, 21600, res.Period) }) }) + + t.Run("Metric query type, metric editor mode and query api mode", func(t *testing.T) { + timeRange := legacydata.NewDataTimeRange("now-1h", "now-2h") + from, err := timeRange.ParseFrom() + require.NoError(t, err) + to, err := timeRange.ParseTo() + require.NoError(t, err) + + t.Run("when metric query type and metric editor mode is not specified", func(t *testing.T) { + t.Run("it should be metric search builder", func(t *testing.T) { + query := getBaseJsonQuery() + res, err := parseRequestQuery(query, "ref1", from, to) + require.NoError(t, err) + assert.Equal(t, MetricQueryTypeSearch, res.MetricQueryType) + assert.Equal(t, MetricEditorModeBuilder, res.MetricEditorMode) + assert.Equal(t, GMDApiModeMetricStat, res.getGMDAPIMode()) + }) + + t.Run("and an expression is specified it should be metric search builder", func(t *testing.T) { + query := getBaseJsonQuery() + query.Set("expression", "SUM(a)") + res, err := parseRequestQuery(query, "ref1", from, to) + require.NoError(t, err) + assert.Equal(t, MetricQueryTypeSearch, res.MetricQueryType) + assert.Equal(t, MetricEditorModeRaw, res.MetricEditorMode) + assert.Equal(t, GMDApiModeMathExpression, res.getGMDAPIMode()) + }) + }) + + t.Run("and an expression is specified it should be metric search builder", func(t *testing.T) { + query := getBaseJsonQuery() + query.Set("expression", "SUM(a)") + res, err := parseRequestQuery(query, "ref1", from, to) + require.NoError(t, err) + assert.Equal(t, MetricQueryTypeSearch, res.MetricQueryType) + assert.Equal(t, MetricEditorModeRaw, res.MetricEditorMode) + assert.Equal(t, GMDApiModeMathExpression, res.getGMDAPIMode()) + }) + }) +} + +func getBaseJsonQuery() *simplejson.Json { + return simplejson.NewFromAny(map[string]interface{}{ + "refId": "ref1", + "region": "us-east-1", + "namespace": "ec2", + "metricName": "CPUUtilization", + "statistic": "Average", + "period": "900", + }) } diff --git a/pkg/tsdb/cloudwatch/response_parser.go b/pkg/tsdb/cloudwatch/response_parser.go index 0e36d309ef0af..bde111585559d 100644 --- a/pkg/tsdb/cloudwatch/response_parser.go +++ b/pkg/tsdb/cloudwatch/response_parser.go @@ -229,19 +229,27 @@ func formatAlias(query *cloudWatchQuery, stat string, dimensions map[string]stri if len(query.Alias) == 0 && query.isInferredSearchExpression() && !query.isMultiValuedDimensionExpression() { return label } + if len(query.Alias) == 0 && query.MetricQueryType == MetricQueryTypeQuery { + return label + } + // common fields data := map[string]string{ - "region": region, - "namespace": namespace, - "metric": metricName, - "stat": stat, - "period": period, + "region": region, + "period": period, } if len(label) != 0 { data["label"] = label } - for k, v := range dimensions { - data[k] = v + + // since the SQL query string is not (yet) parsed, we don't know what namespace, metric, statistic and labels it's using at this point + if query.MetricQueryType != MetricQueryTypeQuery { + data["namespace"] = namespace + data["metric"] = metricName + data["stat"] = stat + for k, v := range dimensions { + data[k] = v + } } result := aliasFormat.ReplaceAllFunc([]byte(query.Alias), func(in []byte) []byte { diff --git a/pkg/tsdb/cloudwatch/response_parser_test.go b/pkg/tsdb/cloudwatch/response_parser_test.go index cdb449d94a6d5..3079092ccb34e 100644 --- a/pkg/tsdb/cloudwatch/response_parser_test.go +++ b/pkg/tsdb/cloudwatch/response_parser_test.go @@ -3,6 +3,7 @@ package cloudwatch import ( "encoding/json" "io/ioutil" + "strings" "testing" "time" @@ -104,9 +105,11 @@ func TestCloudWatchResponseParser(t *testing.T) { "LoadBalancer": {"lb1", "lb2"}, "TargetGroup": {"tg"}, }, - Statistic: "Average", - Period: 60, - Alias: "{{LoadBalancer}} Expanded", + Statistic: "Average", + Period: 60, + Alias: "{{LoadBalancer}} Expanded", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, } frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) @@ -166,9 +169,11 @@ func TestCloudWatchResponseParser(t *testing.T) { "LoadBalancer": {"lb1", "lb2"}, "TargetGroup": {"tg"}, }, - Statistic: "Average", - Period: 60, - Alias: "{{LoadBalancer}} Expanded", + Statistic: "Average", + Period: 60, + Alias: "{{LoadBalancer}} Expanded", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, } frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) @@ -229,9 +234,11 @@ func TestCloudWatchResponseParser(t *testing.T) { "LoadBalancer": {"*"}, "TargetGroup": {"tg"}, }, - Statistic: "Average", - Period: 60, - Alias: "{{LoadBalancer}} Expanded", + Statistic: "Average", + Period: 60, + Alias: "{{LoadBalancer}} Expanded", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, } frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) @@ -266,9 +273,11 @@ func TestCloudWatchResponseParser(t *testing.T) { Dimensions: map[string][]string{ "LoadBalancer": {"lb1", "lb2"}, }, - Statistic: "Average", - Period: 60, - Alias: "{{LoadBalancer}} Expanded", + Statistic: "Average", + Period: 60, + Alias: "{{LoadBalancer}} Expanded", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, } frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) @@ -307,9 +316,11 @@ func TestCloudWatchResponseParser(t *testing.T) { "InstanceType": {"micro"}, "Resource": {"res"}, }, - Statistic: "Average", - Period: 60, - Alias: "{{LoadBalancer}} Expanded {{InstanceType}} - {{Resource}}", + Statistic: "Average", + Period: 60, + Alias: "{{LoadBalancer}} Expanded {{InstanceType}} - {{Resource}}", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, } frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) @@ -319,6 +330,51 @@ func TestCloudWatchResponseParser(t *testing.T) { assert.Equal(t, "lb2 Expanded micro - res", frames[1].Name) }) + t.Run("Should only expand certain fields when using SQL queries", func(t *testing.T) { + timestamp := time.Unix(0, 0) + response := &queryRowResponse{ + Labels: []string{"lb3"}, + Metrics: map[string]*cloudwatch.MetricDataResult{ + "lb3": { + Id: aws.String("lb3"), + Label: aws.String("lb3"), + Timestamps: []*time.Time{ + aws.Time(timestamp), + }, + Values: []*float64{aws.Float64(23)}, + StatusCode: aws.String("Complete"), + }, + }, + } + + query := &cloudWatchQuery{ + RefId: "refId1", + Region: "us-east-1", + Namespace: "AWS/ApplicationELB", + MetricName: "TargetResponseTime", + Dimensions: map[string][]string{ + "LoadBalancer": {"lb1"}, + "InstanceType": {"micro"}, + "Resource": {"res"}, + }, + Statistic: "Average", + Period: 60, + Alias: "{{LoadBalancer}} {{InstanceType}} {{metric}} {{namespace}} {{stat}} {{region}} {{period}}", + MetricQueryType: MetricQueryTypeQuery, + MetricEditorMode: MetricEditorModeRaw, + } + frames, err := buildDataFrames(startTime, endTime, *response, query) + require.NoError(t, err) + + assert.False(t, strings.Contains(frames[0].Name, "AWS/ApplicationELB")) + assert.False(t, strings.Contains(frames[0].Name, "lb1")) + assert.False(t, strings.Contains(frames[0].Name, "micro")) + assert.False(t, strings.Contains(frames[0].Name, "AWS/ApplicationELB")) + + assert.True(t, strings.Contains(frames[0].Name, "us-east-1")) + assert.True(t, strings.Contains(frames[0].Name, "60")) + }) + t.Run("Parse cloudwatch response", func(t *testing.T) { timestamp := time.Unix(0, 0) response := &queryRowResponse{ @@ -351,9 +407,11 @@ func TestCloudWatchResponseParser(t *testing.T) { "LoadBalancer": {"lb"}, "TargetGroup": {"tg"}, }, - Statistic: "Average", - Period: 60, - Alias: "{{namespace}}_{{metric}}_{{stat}}", + Statistic: "Average", + Period: 60, + Alias: "{{namespace}}_{{metric}}_{{stat}}", + MetricQueryType: MetricQueryTypeSearch, + MetricEditorMode: MetricEditorModeBuilder, } frames, err := buildDataFrames(startTime, endTime, *response, query) require.NoError(t, err) diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index 25a18373a1719..d1c373d04b95f 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -91,7 +91,6 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, req *ba resultChan <- &responseWrapper{ DataResponse: &dataResponse, } - return nil, err } close(resultChan) diff --git a/pkg/tsdb/cloudwatch/types.go b/pkg/tsdb/cloudwatch/types.go index bcb55de85df27..1c7bb1d9995cd 100644 --- a/pkg/tsdb/cloudwatch/types.go +++ b/pkg/tsdb/cloudwatch/types.go @@ -31,3 +31,26 @@ type metricStatMeta struct { Stat string `json:"stat"` Period int `json:"period"` } + +type metricQueryType uint32 + +const ( + MetricQueryTypeSearch metricQueryType = iota + MetricQueryTypeQuery +) + +type metricEditorMode uint32 + +const ( + MetricEditorModeBuilder metricEditorMode = iota + MetricEditorModeRaw +) + +type gmdApiMode uint32 + +const ( + GMDApiModeMetricStat gmdApiMode = iota + GMDApiModeInferredSearchExpression + GMDApiModeMathExpression + GMDApiModeSQLExpression +) diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go index 611763efc35ea..ec5d9f68d67b2 100644 --- a/pkg/tsdb/elasticsearch/client/models.go +++ b/pkg/tsdb/elasticsearch/client/models.go @@ -136,8 +136,8 @@ func (f *QueryStringFilter) MarshalJSON() ([]byte, error) { type RangeFilter struct { Filter Key string - Gte string - Lte string + Gte int64 + Lte int64 Format string } @@ -264,8 +264,8 @@ type TermsAggregation struct { // ExtendedBounds represents extended bounds type ExtendedBounds struct { - Min string `json:"min"` - Max string `json:"max"` + Min int64 `json:"min"` + Max int64 `json:"max"` } // GeoHashGridAggregation represents a geo hash grid aggregation diff --git a/pkg/tsdb/elasticsearch/client/search_request.go b/pkg/tsdb/elasticsearch/client/search_request.go index a35de074c88df..c1625adf4e5b8 100644 --- a/pkg/tsdb/elasticsearch/client/search_request.go +++ b/pkg/tsdb/elasticsearch/client/search_request.go @@ -235,7 +235,7 @@ func (b *FilterQueryBuilder) Build() ([]Filter, error) { } // AddDateRangeFilter adds a new time range filter -func (b *FilterQueryBuilder) AddDateRangeFilter(timeField, lte, gte, format string) *FilterQueryBuilder { +func (b *FilterQueryBuilder) AddDateRangeFilter(timeField string, lte, gte int64, format string) *FilterQueryBuilder { b.filters = append(b.filters, &RangeFilter{ Key: timeField, Lte: lte, diff --git a/pkg/tsdb/elasticsearch/client/search_request_test.go b/pkg/tsdb/elasticsearch/client/search_request_test.go index f9c29ac25f3b5..98f99b42f9a4c 100644 --- a/pkg/tsdb/elasticsearch/client/search_request_test.go +++ b/pkg/tsdb/elasticsearch/client/search_request_test.go @@ -50,7 +50,7 @@ func TestSearchRequest(t *testing.T) { b.Size(200) b.SortDesc(timeField, "boolean") filters := b.Query().Bool().Filter() - filters.AddDateRangeFilter(timeField, "$timeTo", "$timeFrom", DateFormatEpochMS) + filters.AddDateRangeFilter(timeField, 10, 5, DateFormatEpochMS) filters.AddQueryStringFilter("test", true) t.Run("When building search request", func(t *testing.T) { @@ -71,8 +71,8 @@ func TestSearchRequest(t *testing.T) { t.Run("Should have range filter", func(t *testing.T) { f, ok := sr.Query.Bool.Filters[0].(*RangeFilter) require.True(t, ok) - require.Equal(t, "$timeFrom", f.Gte) - require.Equal(t, "$timeTo", f.Lte) + require.Equal(t, int64(5), f.Gte) + require.Equal(t, int64(10), f.Lte) require.Equal(t, "epoch_millis", f.Format) }) @@ -95,8 +95,8 @@ func TestSearchRequest(t *testing.T) { require.Equal(t, "boolean", sort.Get("unmapped_type").MustString()) timeRangeFilter := json.GetPath("query", "bool", "filter").GetIndex(0).Get("range").Get(timeField) - require.Equal(t, "$timeFrom", timeRangeFilter.Get("gte").MustString("")) - require.Equal(t, "$timeTo", timeRangeFilter.Get("lte").MustString("")) + require.Equal(t, int64(5), timeRangeFilter.Get("gte").MustInt64()) + require.Equal(t, int64(10), timeRangeFilter.Get("lte").MustInt64()) require.Equal(t, DateFormatEpochMS, timeRangeFilter.Get("format").MustString("")) queryStringFilter := json.GetPath("query", "bool", "filter").GetIndex(1).Get("query_string") diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index 56f2b64f884fd..18cedfd337b87 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -37,8 +37,8 @@ func (e *timeSeriesQuery) execute() (*backend.QueryDataResponse, error) { ms := e.client.MultiSearch() - from := fmt.Sprintf("%d", e.dataQueries[0].TimeRange.From.UnixNano()/int64(time.Millisecond)) - to := fmt.Sprintf("%d", e.dataQueries[0].TimeRange.To.UnixNano()/int64(time.Millisecond)) + from := e.dataQueries[0].TimeRange.From.UnixNano() / int64(time.Millisecond) + to := e.dataQueries[0].TimeRange.To.UnixNano() / int64(time.Millisecond) result := backend.QueryDataResponse{ Responses: backend.Responses{}, } @@ -62,7 +62,7 @@ func (e *timeSeriesQuery) execute() (*backend.QueryDataResponse, error) { return rp.getTimeSeries() } -func (e *timeSeriesQuery) processQuery(q *Query, ms *es.MultiSearchRequestBuilder, from, to string, +func (e *timeSeriesQuery) processQuery(q *Query, ms *es.MultiSearchRequestBuilder, from, to int64, result backend.QueryDataResponse) error { minInterval, err := e.client.GetMinInterval(q.Interval) if err != nil { @@ -243,7 +243,7 @@ func (bucketAgg BucketAgg) generateSettingsForDSL() map[string]interface{} { return bucketAgg.Settings.MustMap() } -func addDateHistogramAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, timeFrom, timeTo string) es.AggBuilder { +func addDateHistogramAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, timeFrom, timeTo int64) es.AggBuilder { aggBuilder.DateHistogram(bucketAgg.ID, bucketAgg.Field, func(a *es.DateHistogramAgg, b es.AggBuilder) { a.Interval = bucketAgg.Settings.Get("interval").MustString("auto") a.MinDocCount = bucketAgg.Settings.Get("min_doc_count").MustInt(0) diff --git a/pkg/tsdb/elasticsearch/time_series_query_test.go b/pkg/tsdb/elasticsearch/time_series_query_test.go index 67b6ce47fe08b..333bebfb11174 100644 --- a/pkg/tsdb/elasticsearch/time_series_query_test.go +++ b/pkg/tsdb/elasticsearch/time_series_query_test.go @@ -2,7 +2,6 @@ package elasticsearch import ( "encoding/json" - "fmt" "testing" "time" @@ -17,8 +16,8 @@ import ( func TestExecuteTimeSeriesQuery(t *testing.T) { from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) - fromStr := fmt.Sprintf("%d", from.UnixNano()/int64(time.Millisecond)) - toStr := fmt.Sprintf("%d", to.UnixNano()/int64(time.Millisecond)) + fromMs := from.UnixNano() / int64(time.Millisecond) + toMs := to.UnixNano() / int64(time.Millisecond) t.Run("Test execute time series query", func(t *testing.T) { t.Run("With defaults on es 2", func(t *testing.T) { @@ -32,14 +31,14 @@ func TestExecuteTimeSeriesQuery(t *testing.T) { sr := c.multisearchRequests[0].Requests[0] rangeFilter := sr.Query.Bool.Filters[0].(*es.RangeFilter) require.Equal(t, rangeFilter.Key, c.timeField) - require.Equal(t, rangeFilter.Lte, toStr) - require.Equal(t, rangeFilter.Gte, fromStr) + require.Equal(t, rangeFilter.Lte, toMs) + require.Equal(t, rangeFilter.Gte, fromMs) require.Equal(t, rangeFilter.Format, es.DateFormatEpochMS) require.Equal(t, sr.Aggs[0].Key, "2") dateHistogramAgg := sr.Aggs[0].Aggregation.Aggregation.(*es.DateHistogramAgg) require.Equal(t, dateHistogramAgg.Field, "@timestamp") - require.Equal(t, dateHistogramAgg.ExtendedBounds.Min, fromStr) - require.Equal(t, dateHistogramAgg.ExtendedBounds.Max, toStr) + require.Equal(t, dateHistogramAgg.ExtendedBounds.Min, fromMs) + require.Equal(t, dateHistogramAgg.ExtendedBounds.Max, toMs) }) t.Run("With defaults on es 5", func(t *testing.T) { @@ -53,8 +52,8 @@ func TestExecuteTimeSeriesQuery(t *testing.T) { sr := c.multisearchRequests[0].Requests[0] require.Equal(t, sr.Query.Bool.Filters[0].(*es.RangeFilter).Key, c.timeField) require.Equal(t, sr.Aggs[0].Key, "2") - require.Equal(t, sr.Aggs[0].Aggregation.Aggregation.(*es.DateHistogramAgg).ExtendedBounds.Min, fromStr) - require.Equal(t, sr.Aggs[0].Aggregation.Aggregation.(*es.DateHistogramAgg).ExtendedBounds.Max, toStr) + require.Equal(t, sr.Aggs[0].Aggregation.Aggregation.(*es.DateHistogramAgg).ExtendedBounds.Min, fromMs) + require.Equal(t, sr.Aggs[0].Aggregation.Aggregation.(*es.DateHistogramAgg).ExtendedBounds.Max, toMs) }) t.Run("With multiple bucket aggs", func(t *testing.T) { @@ -1061,7 +1060,7 @@ func TestSettingsCasting(t *testing.T) { "field": "@timestamp", "id": "2", "settings": { - "interval": "1d" + "interval": "1d" } } ], @@ -1088,7 +1087,7 @@ func TestSettingsCasting(t *testing.T) { "field": "@timestamp", "id": "2", "settings": { - "interval": "1d" + "interval": "1d" } } ], diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index c619c0a39fa2d..422a1a446faf5 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -20,6 +20,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/opentracing/opentracing-go" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" @@ -27,7 +29,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" - "github.com/opentracing/opentracing-go" ) type Service struct { @@ -35,6 +36,11 @@ type Service struct { im instancemgmt.InstanceManager } +const ( + TargetFullModelField = "targetFull" + TargetModelField = "target" +) + func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { s := &Service{ logger: log.New("tsdb.graphite"), @@ -126,10 +132,10 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) } s.logger.Debug("graphite", "query", model) currTarget := "" - if fullTarget, err := model.Get("targetFull").String(); err == nil { + if fullTarget, err := model.Get(TargetFullModelField).String(); err == nil { currTarget = fullTarget } else { - currTarget = model.Get("target").MustString() + currTarget = model.Get(TargetModelField).MustString() } if currTarget == "" { s.logger.Debug("graphite", "empty query target", model) diff --git a/pkg/tsdb/prometheus/json_to_dataframes_test.go b/pkg/tsdb/prometheus/json_to_dataframes_test.go new file mode 100644 index 0000000000000..aaf782ec0ffea --- /dev/null +++ b/pkg/tsdb/prometheus/json_to_dataframes_test.go @@ -0,0 +1,122 @@ +package prometheus + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/experimental" + "github.com/prometheus/client_golang/api" + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/stretchr/testify/require" +) + +func TestMatrixResponses(t *testing.T) { + t.Run("parse a simple matrix response", func(t *testing.T) { + testScenario(t, "range_simple") + }) + + t.Run("parse a simple matrix response with value missing steps", func(t *testing.T) { + testScenario(t, "range_missing") + }) + + t.Run("parse a response with Infinity", func(t *testing.T) { + testScenario(t, "range_infinity") + }) + + t.Run("parse a response with NaN", func(t *testing.T) { + testScenario(t, "range_nan") + }) +} + +type mockedRoundTripper struct { + responseBytes []byte +} + +func (mockedRT *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(mockedRT.responseBytes)), + }, nil +} + +func makeMockedApi(responseBytes []byte) (apiv1.API, error) { + roundTripper := mockedRoundTripper{responseBytes: responseBytes} + + cfg := api.Config{ + Address: "http://localhost:9999", + RoundTripper: &roundTripper, + } + + client, err := api.NewClient(cfg) + if err != nil { + return nil, err + } + + api := apiv1.NewAPI(client) + + return api, nil +} + +// we store the prometheus query data in a json file, here is some minimal code +// to be able to read it back. unfortunately we cannot use the PrometheusQuery +// struct here, because it has `time.time` and `time.duration` fields that +// cannot be unmarshalled from JSON automatically. +type storedPrometheusQuery struct { + RefId string + RangeQuery bool + Start int64 + End int64 + Step int64 + Expr string +} + +func loadStoredPrometheusQuery(t *testing.T, fileName string) PrometheusQuery { + bytes, err := os.ReadFile(fileName) + require.NoError(t, err) + + var query storedPrometheusQuery + + err = json.Unmarshal(bytes, &query) + require.NoError(t, err) + + return PrometheusQuery{ + RefId: query.RefId, + RangeQuery: query.RangeQuery, + Start: time.Unix(query.Start, 0), + End: time.Unix(query.End, 0), + Step: time.Second * time.Duration(query.Step), + Expr: query.Expr, + } +} + +// we run the mocked query, and extract the DataResponse. +// we assume and verify that there is exactly one DataResponse returned. +func testScenario(t *testing.T, name string) { + queryFileName := filepath.Join("testdata", name+".query.json") + responseFileName := filepath.Join("testdata", name+".result.json") + goldenFileName := filepath.Join("testdata", name+".result.golden.txt") + query := loadStoredPrometheusQuery(t, queryFileName) + responseBytes, err := os.ReadFile(responseFileName) + require.NoError(t, err) + + api, err := makeMockedApi(responseBytes) + require.NoError(t, err) + + result, err := runQueries(context.Background(), api, []*PrometheusQuery{&query}) + require.NoError(t, err) + require.Len(t, result.Responses, 1) + + dr, found := result.Responses["A"] + require.True(t, found) + require.NoError(t, dr.Error) + + err = experimental.CheckGoldenDataResponse(goldenFileName, &dr, true) + require.NoError(t, err) +} diff --git a/pkg/tsdb/prometheus/custom_query_params_middleware.go b/pkg/tsdb/prometheus/middleware/custom_query_params.go similarity index 73% rename from pkg/tsdb/prometheus/custom_query_params_middleware.go rename to pkg/tsdb/prometheus/middleware/custom_query_params.go index b5c75c1cb8391..b352c2f6a0d08 100644 --- a/pkg/tsdb/prometheus/custom_query_params_middleware.go +++ b/pkg/tsdb/prometheus/middleware/custom_query_params.go @@ -1,4 +1,4 @@ -package prometheus +package middleware import ( "net/http" @@ -11,14 +11,25 @@ import ( const ( customQueryParametersMiddlewareName = "prom-custom-query-parameters" customQueryParametersKey = "customQueryParameters" + grafanaDataKey = "grafanaData" ) -func customQueryParametersMiddleware(logger log.Logger) sdkhttpclient.Middleware { +func CustomQueryParameters(logger log.Logger) sdkhttpclient.Middleware { return sdkhttpclient.NamedMiddlewareFunc(customQueryParametersMiddlewareName, func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { - customQueryParamsVal, exists := opts.CustomOptions[customQueryParametersKey] + grafanaData, exists := opts.CustomOptions[grafanaDataKey] if !exists { return next } + + data, ok := grafanaData.(map[string]interface{}) + if !ok { + return next + } + customQueryParamsVal, exists := data[customQueryParametersKey] + if !exists { + return next + } + customQueryParams, ok := customQueryParamsVal.(string) if !ok || customQueryParams == "" { return next diff --git a/pkg/tsdb/prometheus/custom_query_params_middleware_test.go b/pkg/tsdb/prometheus/middleware/custom_query_params_test.go similarity index 91% rename from pkg/tsdb/prometheus/custom_query_params_middleware_test.go rename to pkg/tsdb/prometheus/middleware/custom_query_params_test.go index 643b8c3753cc9..fd1e0e22e0ae0 100644 --- a/pkg/tsdb/prometheus/custom_query_params_middleware_test.go +++ b/pkg/tsdb/prometheus/middleware/custom_query_params_test.go @@ -1,4 +1,4 @@ -package prometheus +package middleware import ( "net/http" @@ -19,7 +19,7 @@ func TestCustomQueryParametersMiddleware(t *testing.T) { }) t.Run("Without custom query parameters set should not apply middleware", func(t *testing.T) { - mw := customQueryParametersMiddleware(log.New("test")) + mw := CustomQueryParameters(log.New("test")) rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) require.NotNil(t, rt) middlewareName, ok := mw.(httpclient.MiddlewareName) @@ -39,7 +39,7 @@ func TestCustomQueryParametersMiddleware(t *testing.T) { }) t.Run("Without custom query parameters set as string should not apply middleware", func(t *testing.T) { - mw := customQueryParametersMiddleware(log.New("test")) + mw := CustomQueryParameters(log.New("test")) rt := mw.CreateMiddleware(httpclient.Options{ CustomOptions: map[string]interface{}{ customQueryParametersKey: 64, @@ -63,7 +63,7 @@ func TestCustomQueryParametersMiddleware(t *testing.T) { }) t.Run("With custom query parameters set as empty string should not apply middleware", func(t *testing.T) { - mw := customQueryParametersMiddleware(log.New("test")) + mw := CustomQueryParameters(log.New("test")) rt := mw.CreateMiddleware(httpclient.Options{ CustomOptions: map[string]interface{}{ customQueryParametersKey: "", @@ -87,7 +87,7 @@ func TestCustomQueryParametersMiddleware(t *testing.T) { }) t.Run("With custom query parameters set as invalid query string should not apply middleware", func(t *testing.T) { - mw := customQueryParametersMiddleware(log.New("test")) + mw := CustomQueryParameters(log.New("test")) rt := mw.CreateMiddleware(httpclient.Options{ CustomOptions: map[string]interface{}{ customQueryParametersKey: "custom=%%abc&test=abc", @@ -111,10 +111,12 @@ func TestCustomQueryParametersMiddleware(t *testing.T) { }) t.Run("With custom query parameters set should apply middleware for request URL containing query parameters ", func(t *testing.T) { - mw := customQueryParametersMiddleware(log.New("test")) + mw := CustomQueryParameters(log.New("test")) rt := mw.CreateMiddleware(httpclient.Options{ CustomOptions: map[string]interface{}{ - customQueryParametersKey: "custom=par/am&second=f oo", + grafanaDataKey: map[string]interface{}{ + customQueryParametersKey: "custom=par/am&second=f oo", + }, }, }, finalRoundTripper) require.NotNil(t, rt) @@ -141,10 +143,12 @@ func TestCustomQueryParametersMiddleware(t *testing.T) { }) t.Run("With custom query parameters set should apply middleware for request URL not containing query parameters", func(t *testing.T) { - mw := customQueryParametersMiddleware(log.New("test")) + mw := CustomQueryParameters(log.New("test")) rt := mw.CreateMiddleware(httpclient.Options{ CustomOptions: map[string]interface{}{ - customQueryParametersKey: "custom=par/am&second=f oo", + grafanaDataKey: map[string]interface{}{ + customQueryParametersKey: "custom=par/am&second=f oo", + }, }, }, finalRoundTripper) require.NotNil(t, rt) diff --git a/pkg/tsdb/prometheus/middleware/force_http_get.go b/pkg/tsdb/prometheus/middleware/force_http_get.go new file mode 100644 index 0000000000000..7a679482647bf --- /dev/null +++ b/pkg/tsdb/prometheus/middleware/force_http_get.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/log" +) + +func ForceHttpGet(logger log.Logger) sdkhttpclient.Middleware { + return sdkhttpclient.NamedMiddlewareFunc("force-http-get", func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { + // the prometheus library we use does not allow us to set the http method. + // it's behavior is to first try POST, and if it fails in certain ways + // (for example, by returning a method-not-allowed error), it will try GET. + // so here, we check if the http-method is POST, and if it is, we + // return an artificial method-not-allowed response. + + // this will cause the prometheus library to retry with GET. + return sdkhttpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodPost { + resp := &http.Response{ + StatusCode: http.StatusMethodNotAllowed, + } + return resp, nil + } + + return next.RoundTrip(req) + }) + }) +} diff --git a/pkg/tsdb/prometheus/middleware/force_http_get_test.go b/pkg/tsdb/prometheus/middleware/force_http_get_test.go new file mode 100644 index 0000000000000..21cadae7290a3 --- /dev/null +++ b/pkg/tsdb/prometheus/middleware/force_http_get_test.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +func TestEnsureHttpMethodMiddleware(t *testing.T) { + t.Run("Name should be correct", func(t *testing.T) { + finalRoundTripper := httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + }) + mw := ForceHttpGet(log.New("test")) + rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) + require.NotNil(t, rt) + middlewareName, ok := mw.(httpclient.MiddlewareName) + require.True(t, ok) + require.Equal(t, "force-http-get", middlewareName.MiddlewareName()) + }) + + t.Run("Should force GET method", func(t *testing.T) { + finalRoundTripper := httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + mw := ForceHttpGet(log.New("test")) + rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) + require.NotNil(t, rt) + + req, err := http.NewRequest(http.MethodPost, "http://example.com", nil) + require.NoError(t, err) + res, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, res.StatusCode, http.StatusMethodNotAllowed) + if res.Body != nil { + require.NoError(t, res.Body.Close()) + } + }) +} diff --git a/pkg/tsdb/prometheus/promclient/cache.go b/pkg/tsdb/prometheus/promclient/cache.go new file mode 100644 index 0000000000000..35cad0568dc2f --- /dev/null +++ b/pkg/tsdb/prometheus/promclient/cache.go @@ -0,0 +1,56 @@ +package promclient + +import ( + "sort" + "strings" + + lru "github.com/hashicorp/golang-lru" + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" +) + +type ProviderCache struct { + provider promClientProvider + cache *lru.Cache +} + +type promClientProvider interface { + GetClient(map[string]string) (apiv1.API, error) +} + +func NewProviderCache(p promClientProvider) (*ProviderCache, error) { + cache, err := lru.New(500) + if err != nil { + return nil, err + } + + return &ProviderCache{ + provider: p, + cache: cache, + }, nil +} + +func (c *ProviderCache) GetClient(headers map[string]string) (apiv1.API, error) { + key := c.key(headers) + if client, ok := c.cache.Get(key); ok { + return client.(apiv1.API), nil + } + + client, err := c.provider.GetClient(headers) + if err != nil { + return nil, err + } + + c.cache.Add(key, client) + return client, nil +} + +func (c *ProviderCache) key(headers map[string]string) string { + vals := make([]string, len(headers)) + var i int + for _, v := range headers { + vals[i] = v + i++ + } + sort.Strings(vals) + return strings.Join(vals, "") +} diff --git a/pkg/tsdb/prometheus/promclient/cache_test.go b/pkg/tsdb/prometheus/promclient/cache_test.go new file mode 100644 index 0000000000000..2c27f6c3fea97 --- /dev/null +++ b/pkg/tsdb/prometheus/promclient/cache_test.go @@ -0,0 +1,131 @@ +package promclient_test + +import ( + "context" + "errors" + "sort" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/tsdb/prometheus/promclient" + + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" + + "github.com/stretchr/testify/require" +) + +func TestCache_GetClient(t *testing.T) { + t.Run("it caches the client for a set of auth headers", func(t *testing.T) { + tc := setupCacheContext() + + c, err := tc.providerCache.GetClient(headers) + require.Nil(t, err) + + c2, err := tc.providerCache.GetClient(headers) + require.Nil(t, err) + + require.Equal(t, c, c2) + require.Equal(t, 1, tc.clientProvider.numCalls) + }) + + t.Run("it returns different clients when the headers differ", func(t *testing.T) { + tc := setupCacheContext() + h1 := map[string]string{"Authorization": "token", "X-ID-Token": "id-token"} + h2 := map[string]string{"Authorization": "token2", "X-ID-Token": "id-token"} + + c, err := tc.providerCache.GetClient(h1) + require.Nil(t, err) + + c2, err := tc.providerCache.GetClient(h2) + require.Nil(t, err) + + require.NotEqual(t, c, c2) + require.Equal(t, 2, tc.clientProvider.numCalls) + }) + + t.Run("it returns from the cache when headers are the same", func(t *testing.T) { + tc := setupCacheContext() + h1 := map[string]string{"Authorization": "token", "X-ID-Token": "id-token"} + h2 := map[string]string{"Authorization": "token", "X-ID-Token": "id-token"} + + c, err := tc.providerCache.GetClient(h1) + require.Nil(t, err) + + c2, err := tc.providerCache.GetClient(h2) + require.Nil(t, err) + + require.Equal(t, c, c2) + require.Equal(t, 1, tc.clientProvider.numCalls) + }) + + t.Run("it doesn't cache anything when an error occurs", func(t *testing.T) { + tc := setupCacheContext() + tc.clientProvider.errors <- errors.New("something bad") + + _, err := tc.providerCache.GetClient(headers) + require.EqualError(t, err, "something bad") + + c, err := tc.providerCache.GetClient(headers) + require.Nil(t, err) + + require.NotNil(t, c) + require.Equal(t, 2, tc.clientProvider.numCalls) + }) +} + +type cacheTestContext struct { + providerCache *promclient.ProviderCache + clientProvider *fakePromClientProvider +} + +func setupCacheContext() *cacheTestContext { + fp := newFakePromClientProvider() + p, err := promclient.NewProviderCache(fp) + if err != nil { + panic(err) + } + + return &cacheTestContext{ + providerCache: p, + clientProvider: fp, + } +} + +func newFakePromClientProvider() *fakePromClientProvider { + return &fakePromClientProvider{ + errors: make(chan error, 1), + } +} + +type fakePromClientProvider struct { + headers map[string]string + numCalls int + errors chan error +} + +func (p *fakePromClientProvider) GetClient(h map[string]string) (apiv1.API, error) { + p.headers = h + p.numCalls++ + + var err error + select { + case err = <-p.errors: + default: + } + + var config []string + for _, v := range h { + config = append(config, v) + } + sort.Strings(config) //because map + return &fakePromClient{config: strings.Join(config, "")}, err +} + +type fakePromClient struct { + apiv1.API + config string +} + +func (c *fakePromClient) Config(ctx context.Context) (apiv1.ConfigResult, error) { + return apiv1.ConfigResult{YAML: c.config}, nil +} diff --git a/pkg/tsdb/prometheus/promclient/provider.go b/pkg/tsdb/prometheus/promclient/provider.go new file mode 100644 index 0000000000000..c84a965177303 --- /dev/null +++ b/pkg/tsdb/prometheus/promclient/provider.go @@ -0,0 +1,94 @@ +package promclient + +import ( + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/grafana/pkg/tsdb/prometheus/middleware" + + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/httpclient" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/prometheus/client_golang/api" + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" +) + +type Provider struct { + settings backend.DataSourceInstanceSettings + jsonData JsonData + clientProvider httpclient.Provider + log log.Logger +} + +func NewProvider( + settings backend.DataSourceInstanceSettings, + jsonData JsonData, + clientProvider httpclient.Provider, + log log.Logger, +) *Provider { + return &Provider{ + settings: settings, + jsonData: jsonData, + clientProvider: clientProvider, + log: log, + } +} + +type JsonData struct { + Method string `json:"httpMethod"` + TimeInterval string `json:"timeInterval"` +} + +func (p *Provider) GetClient(headers map[string]string) (apiv1.API, error) { + opts, err := p.settings.HTTPClientOptions() + if err != nil { + return nil, err + } + + opts.Middlewares = p.middlewares() + opts.Headers = reqHeaders(headers) + + // Set SigV4 service namespace + if opts.SigV4 != nil { + opts.SigV4.Service = "aps" + } + + roundTripper, err := p.clientProvider.GetTransport(opts) + if err != nil { + return nil, err + } + + cfg := api.Config{ + Address: p.settings.URL, + RoundTripper: roundTripper, + } + + client, err := api.NewClient(cfg) + if err != nil { + return nil, err + } + + return apiv1.NewAPI(client), nil +} + +func (p *Provider) middlewares() []sdkhttpclient.Middleware { + middlewares := []sdkhttpclient.Middleware{ + middleware.CustomQueryParameters(p.log), + sdkhttpclient.CustomHeadersMiddleware(), + } + if strings.ToLower(p.jsonData.Method) == "get" { + middlewares = append(middlewares, middleware.ForceHttpGet(p.log)) + } + + return middlewares +} + +func reqHeaders(headers map[string]string) map[string]string { + // copy to avoid changing the original map + h := make(map[string]string, len(headers)) + for k, v := range headers { + h[k] = v + } + return h +} diff --git a/pkg/tsdb/prometheus/promclient/provider_test.go b/pkg/tsdb/prometheus/promclient/provider_test.go new file mode 100644 index 0000000000000..935b3d07c5acb --- /dev/null +++ b/pkg/tsdb/prometheus/promclient/provider_test.go @@ -0,0 +1,178 @@ +package promclient_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/tsdb/prometheus/promclient" + + "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/infra/httpclient" + + "github.com/stretchr/testify/require" +) + +var headers = map[string]string{"Authorization": "token", "X-ID-Token": "id-token"} + +func TestGetClient(t *testing.T) { + t.Run("it sets the SigV4 service if it exists", func(t *testing.T) { + tc := setup(`{"sigV4Auth":true}`) + + setting.SigV4AuthEnabled = true + defer func() { setting.SigV4AuthEnabled = false }() + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.Equal(t, "aps", tc.httpProvider.opts.SigV4.Service) + }) + + t.Run("it always uses the custom params and custom headers middlewares", func(t *testing.T) { + tc := setup() + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.Len(t, tc.httpProvider.middlewares(), 2) + require.Contains(t, tc.httpProvider.middlewares(), "prom-custom-query-parameters") + require.Contains(t, tc.httpProvider.middlewares(), "CustomHeaders") + }) + + t.Run("extra headers", func(t *testing.T) { + t.Run("it sets the headers when 'oauthPassThru' is true and auth headers are passed", func(t *testing.T) { + tc := setup(`{"oauthPassThru":true}`) + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.Equal(t, headers, tc.httpProvider.opts.Headers) + }) + + t.Run("it sets all headers", func(t *testing.T) { + withNonAuth := map[string]string{"X-Not-Auth": "stuff"} + + tc := setup(`{"oauthPassThru":true}`) + _, err := tc.promClientProvider.GetClient(withNonAuth) + require.Nil(t, err) + + require.Equal(t, map[string]string{"X-Not-Auth": "stuff"}, tc.httpProvider.opts.Headers) + }) + + t.Run("it does not error when headers are nil", func(t *testing.T) { + tc := setup(`{"oauthPassThru":true}`) + + _, err := tc.promClientProvider.GetClient(nil) + require.Nil(t, err) + }) + }) + + t.Run("force get middleware", func(t *testing.T) { + t.Run("it add the force-get middleware when httpMethod is get", func(t *testing.T) { + tc := setup(`{"httpMethod":"get"}`) + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.Len(t, tc.httpProvider.middlewares(), 3) + require.Contains(t, tc.httpProvider.middlewares(), "force-http-get") + }) + + t.Run("it add the force-get middleware when httpMethod is get", func(t *testing.T) { + tc := setup(`{"httpMethod":"GET"}`) + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.Len(t, tc.httpProvider.middlewares(), 3) + require.Contains(t, tc.httpProvider.middlewares(), "force-http-get") + }) + + t.Run("it does not add the force-get middleware when httpMethod is POST", func(t *testing.T) { + tc := setup(`{"httpMethod":"POST"}`) + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.NotContains(t, tc.httpProvider.middlewares(), "force-http-get") + }) + + t.Run("it does not add the force-get middleware when json data is nil", func(t *testing.T) { + tc := setup() + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.NotContains(t, tc.httpProvider.middlewares(), "force-http-get") + }) + + t.Run("it does not add the force-get middleware when json data is empty", func(t *testing.T) { + tc := setup(`{}`) + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.NotContains(t, tc.httpProvider.middlewares(), "force-http-get") + }) + + t.Run("it does not add the force-get middleware httpMethod is null", func(t *testing.T) { + tc := setup(`{"httpMethod":null}`) + + _, err := tc.promClientProvider.GetClient(headers) + require.Nil(t, err) + + require.NotContains(t, tc.httpProvider.middlewares(), "force-http-get") + }) + }) +} + +func setup(jsonData ...string) *testContext { + var rawData []byte + if len(jsonData) > 0 { + rawData = []byte(jsonData[0]) + } + + var jd promclient.JsonData + _ = json.Unmarshal(rawData, &jd) + + settings := backend.DataSourceInstanceSettings{URL: "test-url", JSONData: rawData} + hp := &fakeHttpClientProvider{} + p := promclient.NewProvider(settings, jd, hp, nil) + + return &testContext{ + httpProvider: hp, + promClientProvider: p, + } +} + +type testContext struct { + httpProvider *fakeHttpClientProvider + promClientProvider *promclient.Provider +} + +type fakeHttpClientProvider struct { + httpclient.Provider + + opts sdkhttpclient.Options +} + +func (p *fakeHttpClientProvider) GetTransport(opts ...sdkhttpclient.Options) (http.RoundTripper, error) { + p.opts = opts[0] + return http.DefaultTransport, nil +} + +func (p *fakeHttpClientProvider) middlewares() []string { + var middlewareNames []string + for _, m := range p.opts.Middlewares { + mw, ok := m.(sdkhttpclient.MiddlewareName) + if !ok { + panic("unexpected middleware type") + } + + middlewareNames = append(middlewareNames, mw.MiddlewareName()) + } + return middlewareNames +} diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index bd0bd34ded7aa..595378c4b228d 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -7,16 +7,16 @@ import ( "fmt" "regexp" + "github.com/grafana/grafana/pkg/tsdb/prometheus/promclient" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" - sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/tsdb/intervalv2" - "github.com/prometheus/client_golang/api" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" ) @@ -53,36 +53,14 @@ func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.Co func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc { return func(settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { - jsonData := map[string]interface{}{} + var jsonData promclient.JsonData err := json.Unmarshal(settings.JSONData, &jsonData) if err != nil { return nil, fmt.Errorf("error reading settings: %w", err) } - httpCliOpts, err := settings.HTTPClientOptions() - if err != nil { - return nil, fmt.Errorf("error getting http options: %w", err) - } - - // Set SigV4 service namespace - if httpCliOpts.SigV4 != nil { - httpCliOpts.SigV4.Service = "aps" - } - // timeInterval can be a string or can be missing. - // if it is missing, we set it to empty-string - timeInterval := "" - - timeIntervalJson := jsonData["timeInterval"] - if timeIntervalJson != nil { - // if it is not nil, it must be a string - var ok bool - timeInterval, ok = timeIntervalJson.(string) - if !ok { - return nil, errors.New("invalid time-interval provided") - } - } - - client, err := createClient(settings.URL, httpCliOpts, httpClientProvider) + p := promclient.NewProvider(settings, jsonData, httpClientProvider, plog) + pc, err := promclient.NewProviderCache(p) if err != nil { return nil, err } @@ -90,8 +68,8 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst mdl := DatasourceInfo{ ID: settings.ID, URL: settings.URL, - TimeInterval: timeInterval, - promClient: client, + TimeInterval: jsonData.TimeInterval, + getClient: pc.GetClient, } return mdl, nil @@ -120,28 +98,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return result, err } -func createClient(url string, httpOpts sdkhttpclient.Options, clientProvider httpclient.Provider) (apiv1.API, error) { - customMiddlewares := customQueryParametersMiddleware(plog) - httpOpts.Middlewares = []sdkhttpclient.Middleware{customMiddlewares} - - roundTripper, err := clientProvider.GetTransport(httpOpts) - if err != nil { - return nil, err - } - - cfg := api.Config{ - Address: url, - RoundTripper: roundTripper, - } - - client, err := api.NewClient(cfg) - if err != nil { - return nil, err - } - - return apiv1.NewAPI(client), nil -} - func (s *Service) getDSInfo(pluginCtx backend.PluginContext) (*DatasourceInfo, error) { i, err := s.im.Get(pluginCtx) if err != nil { diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.query.json b/pkg/tsdb/prometheus/testdata/range_infinity.query.json new file mode 100644 index 0000000000000..76d612b682408 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_infinity.query.json @@ -0,0 +1,8 @@ +{ + "RefId": "A", + "RangeQuery": true, + "Start": 1641889530, + "End": 1641889532, + "Step": 1, + "Expr": "1 / 0" +} diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.txt b/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.txt new file mode 100644 index 0000000000000..2aad902f0db47 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_infinity.result.golden.txt @@ -0,0 +1,22 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "custom": { + "resultType": "matrix" + } +} +Name: 1 / 0 +Dimensions: 2 Fields by 3 Rows ++-------------------------------+------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: | +| Type: []time.Time | Type: []*float64 | ++-------------------------------+------------------+ +| 2022-01-11 08:25:30 +0000 UTC | +Inf | +| 2022-01-11 08:25:31 +0000 UTC | +Inf | +| 2022-01-11 08:25:32 +0000 UTC | +Inf | ++-------------------------------+------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////KAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAJgAAAADAAAAUAAAACgAAAAEAAAAbP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAACM/v//CAAAABAAAAAFAAAAMSAvIDAAAAAEAAAAbmFtZQAAAACw/v//CAAAACwAAAAiAAAAeyJjdXN0b20iOnsicmVzdWx0VHlwZSI6Im1hdHJpeCJ9fQAABAAAAG1ldGEAAAAAAgAAAOwAAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAKAAAACgAAAAAAADAaAAAAADAAAAUAAAACwAAAAEAAAAOP///wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAXP///wgAAAAMAAAAAgAAAHt9AAAGAAAAbGFiZWxzAAB8////CAAAACgAAAAdAAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IjEgLyAwIn0AAAAGAAAAY29uZmlnAAAAAAAAiv///wAAAgAFAAAAVmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAMAAAAAAAAAAUAAAAAAAAAwMACgAYAAwACAAEAAoAAAAUAAAAWAAAAAMAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAAAAAAAAIAAAADAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAABEFRTUKckWAA6wT9QpyRYA2EqL1CnJFgAAAAAAAPB/AAAAAAAA8H8AAAAAAADwfxAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAADAAEAAAA4AgAAAAAAAMAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAAmAAAAAMAAABQAAAAKAAAAAQAAABs/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAIz+//8IAAAAEAAAAAUAAAAxIC8gMAAAAAQAAABuYW1lAAAAALD+//8IAAAALAAAACIAAAB7ImN1c3RvbSI6eyJyZXN1bHRUeXBlIjoibWF0cml4In19AAAEAAAAbWV0YQAAAAACAAAA7AAAABgAAAAAABIAGAAUABMAEgAMAAAACAAEABIAAAAUAAAAoAAAAKAAAAAAAAMBoAAAAAMAAABQAAAALAAAAAQAAAA4////CAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAABc////CAAAAAwAAAACAAAAe30AAAYAAABsYWJlbHMAAHz///8IAAAAKAAAAB0AAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoiMSAvIDAifQAAAAYAAABjb25maWcAAAAAAACK////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAABQAgAAQVJST1cx diff --git a/pkg/tsdb/prometheus/testdata/range_infinity.result.json b/pkg/tsdb/prometheus/testdata/range_infinity.result.json new file mode 100644 index 0000000000000..7059de48f6f69 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_infinity.result.json @@ -0,0 +1,16 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": {}, + "values": [ + [1641889530, "+Inf"], + [1641889531, "+Inf"], + [1641889532, "+Inf"] + ] + } + ] + } +} diff --git a/pkg/tsdb/prometheus/testdata/range_missing.query.json b/pkg/tsdb/prometheus/testdata/range_missing.query.json new file mode 100644 index 0000000000000..3388e0d45b96e --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_missing.query.json @@ -0,0 +1,8 @@ +{ + "RefId": "A", + "RangeQuery": true, + "Start": 1641889530, + "End": 1641889538, + "Step": 1, + "Expr": "test1" +} diff --git a/pkg/tsdb/prometheus/testdata/range_missing.result.golden.txt b/pkg/tsdb/prometheus/testdata/range_missing.result.golden.txt new file mode 100644 index 0000000000000..e63cc8828b8b9 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_missing.result.golden.txt @@ -0,0 +1,28 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "custom": { + "resultType": "matrix" + } +} +Name: go_goroutines{job="prometheus"} +Dimensions: 2 Fields by 9 Rows ++-------------------------------+------------------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: __name__=go_goroutines, job=prometheus | +| Type: []time.Time | Type: []*float64 | ++-------------------------------+------------------------------------------------+ +| 2022-01-11 08:25:30 +0000 UTC | null | +| 2022-01-11 08:25:31 +0000 UTC | null | +| 2022-01-11 08:25:32 +0000 UTC | null | +| 2022-01-11 08:25:33 +0000 UTC | 21 | +| 2022-01-11 08:25:34 +0000 UTC | 32 | +| 2022-01-11 08:25:35 +0000 UTC | null | +| 2022-01-11 08:25:36 +0000 UTC | null | +| 2022-01-11 08:25:37 +0000 UTC | 43 | +| 2022-01-11 08:25:38 +0000 UTC | null | ++-------------------------------+------------------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////iAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAALAAAAADAAAAaAAAACgAAAAEAAAADP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAAs/v//CAAAACgAAAAfAAAAZ29fZ29yb3V0aW5lc3tqb2I9InByb21ldGhldXMifQAEAAAAbmFtZQAAAABo/v//CAAAACwAAAAiAAAAeyJjdXN0b20iOnsicmVzdWx0VHlwZSI6Im1hdHJpeCJ9fQAABAAAAG1ldGEAAAAAAgAAADQBAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAOgAAADoAAAAAAADAegAAAADAAAAfAAAACwAAAAEAAAA8P7//wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAFP///wgAAAA4AAAALwAAAHsiX19uYW1lX18iOiJnb19nb3JvdXRpbmVzIiwiam9iIjoicHJvbWV0aGV1cyJ9AAYAAABsYWJlbHMAAGD///8IAAAARAAAADkAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoiZ29fZ29yb3V0aW5lc3tqb2I9XCJwcm9tZXRoZXVzXCJ9In0AAAAGAAAAY29uZmlnAAAAAAAAiv///wAAAgAFAAAAVmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAmAAAAAAAAAAUAAAAAAAAAwMACgAYAAwACAAEAAoAAAAUAAAAWAAAAAkAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIAAAAAAAAAEgAAAAAAAAACAAAAAAAAABQAAAAAAAAAEgAAAAAAAAAAAAAAAIAAAAJAAAAAAAAAAAAAAAAAAAACQAAAAAAAAAGAAAAAAAAAABEFRTUKckWAA6wT9QpyRYA2EqL1CnJFgCi5cbUKckWAGyAAtUpyRYANhs+1SnJFgAAtnnVKckWAMpQtdUpyRYAlOvw1SnJFpgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANUAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAIBFQAAAAAAAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAMAAQAAAJgCAAAAAAAAwAAAAAAAAACYAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAACwAAAAAwAAAGgAAAAoAAAABAAAAAz+//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAALP7//wgAAAAoAAAAHwAAAGdvX2dvcm91dGluZXN7am9iPSJwcm9tZXRoZXVzIn0ABAAAAG5hbWUAAAAAaP7//wgAAAAsAAAAIgAAAHsiY3VzdG9tIjp7InJlc3VsdFR5cGUiOiJtYXRyaXgifX0AAAQAAABtZXRhAAAAAAIAAAA0AQAAGAAAAAAAEgAYABQAEwASAAwAAAAIAAQAEgAAABQAAADoAAAA6AAAAAAAAwHoAAAAAwAAAHwAAAAsAAAABAAAAPD+//8IAAAAEAAAAAUAAABWYWx1ZQAAAAQAAABuYW1lAAAAABT///8IAAAAOAAAAC8AAAB7Il9fbmFtZV9fIjoiZ29fZ29yb3V0aW5lcyIsImpvYiI6InByb21ldGhldXMifQAGAAAAbGFiZWxzAABg////CAAAAEQAAAA5AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6ImdvX2dvcm91dGluZXN7am9iPVwicHJvbWV0aGV1c1wifSJ9AAAABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAALACAABBUlJPVzE= diff --git a/pkg/tsdb/prometheus/testdata/range_missing.result.json b/pkg/tsdb/prometheus/testdata/range_missing.result.json new file mode 100644 index 0000000000000..9ace3676baf20 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_missing.result.json @@ -0,0 +1,16 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { "__name__": "go_goroutines", "job": "prometheus" }, + "values": [ + [1641889533, "21"], + [1641889534, "32"], + [1641889537, "43"] + ] + } + ] + } +} diff --git a/pkg/tsdb/prometheus/testdata/range_nan.query.json b/pkg/tsdb/prometheus/testdata/range_nan.query.json new file mode 100644 index 0000000000000..6a5698600d25d --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_nan.query.json @@ -0,0 +1,7 @@ +{ + "RefId": "A", + "RangeQuery": true, + "Start": 1641889530, + "End": 1641889532, + "Step": 1 +} diff --git a/pkg/tsdb/prometheus/testdata/range_nan.result.golden.txt b/pkg/tsdb/prometheus/testdata/range_nan.result.golden.txt new file mode 100644 index 0000000000000..f4d34e2a377c3 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_nan.result.golden.txt @@ -0,0 +1,22 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "custom": { + "resultType": "matrix" + } +} +Name: {handler="/api/v1/query_range", job="prometheus"} +Dimensions: 2 Fields by 3 Rows ++-------------------------------+-----------------------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: handler=/api/v1/query_range, job=prometheus | +| Type: []time.Time | Type: []*float64 | ++-------------------------------+-----------------------------------------------------+ +| 2022-01-11 08:25:30 +0000 UTC | null | +| 2022-01-11 08:25:31 +0000 UTC | null | +| 2022-01-11 08:25:32 +0000 UTC | null | ++-------------------------------+-----------------------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////uAIAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAMQAAAADAAAAfAAAACgAAAAEAAAA3P3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAAD8/f//CAAAADwAAAAxAAAAe2hhbmRsZXI9Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCBqb2I9InByb21ldGhldXMifQAAAAQAAABuYW1lAAAAAEz+//8IAAAALAAAACIAAAB7ImN1c3RvbSI6eyJyZXN1bHRUeXBlIjoibWF0cml4In19AAAEAAAAbWV0YQAAAAACAAAAUAEAABgAAAAAABIAGAAUABMAEgAMAAAACAAEABIAAAAUAAAABAEAAAQBAAAAAAMBBAEAAAMAAACEAAAALAAAAAQAAADU/v//CAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAAD4/v//CAAAAEAAAAA0AAAAeyJoYW5kbGVyIjoiL2FwaS92MS9xdWVyeV9yYW5nZSIsImpvYiI6InByb21ldGhldXMifQAAAAAGAAAAbGFiZWxzAABM////CAAAAFgAAABNAAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6IntoYW5kbGVyPVwiL2FwaS92MS9xdWVyeV9yYW5nZVwiLCBqb2I9XCJwcm9tZXRoZXVzXCJ9In0AAAAGAAAAY29uZmlnAAAAAAAAiv///wAAAgAFAAAAVmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAOAAAAAAAAAAUAAAAAAAAAwMACgAYAAwACAAEAAoAAAAUAAAAWAAAAAMAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAACAAAAAAAAAAgAAAAAAAAABgAAAAAAAAAAAAAAAIAAAADAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAADAAAAAAAAAABEFRTUKckWAA6wT9QpyRYA2EqL1CnJFgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADgAAAAAAAMAAQAAAMgCAAAAAAAAwAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADEAAAAAwAAAHwAAAAoAAAABAAAANz9//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAA/P3//wgAAAA8AAAAMQAAAHtoYW5kbGVyPSIvYXBpL3YxL3F1ZXJ5X3JhbmdlIiwgam9iPSJwcm9tZXRoZXVzIn0AAAAEAAAAbmFtZQAAAABM/v//CAAAACwAAAAiAAAAeyJjdXN0b20iOnsicmVzdWx0VHlwZSI6Im1hdHJpeCJ9fQAABAAAAG1ldGEAAAAAAgAAAFABAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAAQBAAAEAQAAAAADAQQBAAADAAAAhAAAACwAAAAEAAAA1P7//wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAA+P7//wgAAABAAAAANAAAAHsiaGFuZGxlciI6Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAABgAAAGxhYmVscwAATP///wgAAABYAAAATQAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJ7aGFuZGxlcj1cIi9hcGkvdjEvcXVlcnlfcmFuZ2VcIiwgam9iPVwicHJvbWV0aGV1c1wifSJ9AAAABgAAAGNvbmZpZwAAAAAAAIr///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEwAAAAAAAAKTAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAAAAAAAAAGAAgABgAGAAAAAAADAAQAAABUaW1lAAAAAOACAABBUlJPVzE= diff --git a/pkg/tsdb/prometheus/testdata/range_nan.result.json b/pkg/tsdb/prometheus/testdata/range_nan.result.json new file mode 100644 index 0000000000000..06517c9a48892 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_nan.result.json @@ -0,0 +1,16 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { "handler": "/api/v1/query_range", "job": "prometheus" }, + "values": [ + [1641889530, "NaN"], + [1641889531, "NaN"], + [1641889532, "NaN"] + ] + } + ] + } +} diff --git a/pkg/tsdb/prometheus/testdata/range_simple.query.json b/pkg/tsdb/prometheus/testdata/range_simple.query.json new file mode 100644 index 0000000000000..6a5698600d25d --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_simple.query.json @@ -0,0 +1,7 @@ +{ + "RefId": "A", + "RangeQuery": true, + "Start": 1641889530, + "End": 1641889532, + "Step": 1 +} diff --git a/pkg/tsdb/prometheus/testdata/range_simple.result.golden.txt b/pkg/tsdb/prometheus/testdata/range_simple.result.golden.txt new file mode 100644 index 0000000000000..7866187f0e6ee --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_simple.result.golden.txt @@ -0,0 +1,42 @@ +🌟 This was machine generated. Do not edit. 🌟 + +Frame[0] { + "custom": { + "resultType": "matrix" + } +} +Name: prometheus_http_requests_total{code="200", handler="/api/v1/query_range", job="prometheus"} +Dimensions: 2 Fields by 3 Rows ++-------------------------------+--------------------------------------------------------------------------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: __name__=prometheus_http_requests_total, code=200, handler=/api/v1/query_range, job=prometheus | +| Type: []time.Time | Type: []*float64 | ++-------------------------------+--------------------------------------------------------------------------------------------------------+ +| 2022-01-11 08:25:30 +0000 UTC | 21 | +| 2022-01-11 08:25:31 +0000 UTC | 32 | +| 2022-01-11 08:25:32 +0000 UTC | 43 | ++-------------------------------+--------------------------------------------------------------------------------------------------------+ + + + +Frame[1] { + "custom": { + "resultType": "matrix" + } +} +Name: prometheus_http_requests_total{code="400", handler="/api/v1/query_range", job="prometheus"} +Dimensions: 2 Fields by 3 Rows ++-------------------------------+--------------------------------------------------------------------------------------------------------+ +| Name: Time | Name: Value | +| Labels: | Labels: __name__=prometheus_http_requests_total, code=400, handler=/api/v1/query_range, job=prometheus | +| Type: []time.Time | Type: []*float64 | ++-------------------------------+--------------------------------------------------------------------------------------------------------+ +| 2022-01-11 08:25:30 +0000 UTC | 54 | +| 2022-01-11 08:25:31 +0000 UTC | 65 | +| 2022-01-11 08:25:32 +0000 UTC | 76 | ++-------------------------------+--------------------------------------------------------------------------------------------------------+ + + +====== TEST DATA RESPONSE (arrow base64) ====== +FRAME=QVJST1cxAAD/////QAMAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAOwAAAADAAAApAAAACgAAAAEAAAAUP3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABw/f//CAAAAGQAAABbAAAAcHJvbWV0aGV1c19odHRwX3JlcXVlc3RzX3RvdGFse2NvZGU9IjIwMCIsIGhhbmRsZXI9Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCBqb2I9InByb21ldGhldXMifQAEAAAAbmFtZQAAAADo/f//CAAAACwAAAAiAAAAeyJjdXN0b20iOnsicmVzdWx0VHlwZSI6Im1hdHJpeCJ9fQAABAAAAG1ldGEAAAAAAgAAALQBAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAGgBAABoAQAAAAADAWgBAAADAAAAvAAAACwAAAAEAAAAcP7//wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAlP7//wgAAAB4AAAAbQAAAHsiX19uYW1lX18iOiJwcm9tZXRoZXVzX2h0dHBfcmVxdWVzdHNfdG90YWwiLCJjb2RlIjoiMjAwIiwiaGFuZGxlciI6Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAGAAAAbGFiZWxzAAAg////CAAAAIQAAAB5AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6InByb21ldGhldXNfaHR0cF9yZXF1ZXN0c190b3RhbHtjb2RlPVwiMjAwXCIsIGhhbmRsZXI9XCIvYXBpL3YxL3F1ZXJ5X3JhbmdlXCIsIGpvYj1cInByb21ldGhldXNcIn0ifQAAAAYAAABjb25maWcAAAAAAACK////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAD/////uAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAADAAAAAAAAAAFAAAAAAAAAMDAAoAGAAMAAgABAAKAAAAFAAAAFgAAAADAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAYAAAAAAAAAAAAAAACAAAAAwAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAARBUU1CnJFgAOsE/UKckWANhKi9QpyRYAAAAAAAA1QAAAAAAAAEBAAAAAAACARUAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAAAwABAAAAUAMAAAAAAADAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADsAAAAAwAAAKQAAAAoAAAABAAAAFD9//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAcP3//wgAAABkAAAAWwAAAHByb21ldGhldXNfaHR0cF9yZXF1ZXN0c190b3RhbHtjb2RlPSIyMDAiLCBoYW5kbGVyPSIvYXBpL3YxL3F1ZXJ5X3JhbmdlIiwgam9iPSJwcm9tZXRoZXVzIn0ABAAAAG5hbWUAAAAA6P3//wgAAAAsAAAAIgAAAHsiY3VzdG9tIjp7InJlc3VsdFR5cGUiOiJtYXRyaXgifX0AAAQAAABtZXRhAAAAAAIAAAC0AQAAGAAAAAAAEgAYABQAEwASAAwAAAAIAAQAEgAAABQAAABoAQAAaAEAAAAAAwFoAQAAAwAAALwAAAAsAAAABAAAAHD+//8IAAAAEAAAAAUAAABWYWx1ZQAAAAQAAABuYW1lAAAAAJT+//8IAAAAeAAAAG0AAAB7Il9fbmFtZV9fIjoicHJvbWV0aGV1c19odHRwX3JlcXVlc3RzX3RvdGFsIiwiY29kZSI6IjIwMCIsImhhbmRsZXIiOiIvYXBpL3YxL3F1ZXJ5X3JhbmdlIiwiam9iIjoicHJvbWV0aGV1cyJ9AAAABgAAAGxhYmVscwAAIP///wgAAACEAAAAeQAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJwcm9tZXRoZXVzX2h0dHBfcmVxdWVzdHNfdG90YWx7Y29kZT1cIjIwMFwiLCBoYW5kbGVyPVwiL2FwaS92MS9xdWVyeV9yYW5nZVwiLCBqb2I9XCJwcm9tZXRoZXVzXCJ9In0AAAAGAAAAY29uZmlnAAAAAAAAiv///wAAAgAFAAAAVmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAcAMAAEFSUk9XMQ== +FRAME=QVJST1cxAAD/////QAMAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAOwAAAADAAAApAAAACgAAAAEAAAAUP3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABw/f//CAAAAGQAAABbAAAAcHJvbWV0aGV1c19odHRwX3JlcXVlc3RzX3RvdGFse2NvZGU9IjQwMCIsIGhhbmRsZXI9Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCBqb2I9InByb21ldGhldXMifQAEAAAAbmFtZQAAAADo/f//CAAAACwAAAAiAAAAeyJjdXN0b20iOnsicmVzdWx0VHlwZSI6Im1hdHJpeCJ9fQAABAAAAG1ldGEAAAAAAgAAALQBAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAGgBAABoAQAAAAADAWgBAAADAAAAvAAAACwAAAAEAAAAcP7//wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAlP7//wgAAAB4AAAAbQAAAHsiX19uYW1lX18iOiJwcm9tZXRoZXVzX2h0dHBfcmVxdWVzdHNfdG90YWwiLCJjb2RlIjoiNDAwIiwiaGFuZGxlciI6Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAGAAAAbGFiZWxzAAAg////CAAAAIQAAAB5AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6InByb21ldGhldXNfaHR0cF9yZXF1ZXN0c190b3RhbHtjb2RlPVwiNDAwXCIsIGhhbmRsZXI9XCIvYXBpL3YxL3F1ZXJ5X3JhbmdlXCIsIGpvYj1cInByb21ldGhldXNcIn0ifQAAAAYAAABjb25maWcAAAAAAACK////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABMAAAAAAAACkwAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAFRpbWUAAAAABAAAAG5hbWUAAAAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAAD/////uAAAABQAAAAAAAAADAAWABQAEwAMAAQADAAAADAAAAAAAAAAFAAAAAAAAAMDAAoAGAAMAAgABAAKAAAAFAAAAFgAAAADAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAYAAAAAAAAAAAAAAACAAAAAwAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAARBUU1CnJFgAOsE/UKckWANhKi9QpyRYAAAAAAABLQAAAAAAAQFBAAAAAAAAAU0AQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAAAwABAAAAUAMAAAAAAADAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAADsAAAAAwAAAKQAAAAoAAAABAAAAFD9//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAAcP3//wgAAABkAAAAWwAAAHByb21ldGhldXNfaHR0cF9yZXF1ZXN0c190b3RhbHtjb2RlPSI0MDAiLCBoYW5kbGVyPSIvYXBpL3YxL3F1ZXJ5X3JhbmdlIiwgam9iPSJwcm9tZXRoZXVzIn0ABAAAAG5hbWUAAAAA6P3//wgAAAAsAAAAIgAAAHsiY3VzdG9tIjp7InJlc3VsdFR5cGUiOiJtYXRyaXgifX0AAAQAAABtZXRhAAAAAAIAAAC0AQAAGAAAAAAAEgAYABQAEwASAAwAAAAIAAQAEgAAABQAAABoAQAAaAEAAAAAAwFoAQAAAwAAALwAAAAsAAAABAAAAHD+//8IAAAAEAAAAAUAAABWYWx1ZQAAAAQAAABuYW1lAAAAAJT+//8IAAAAeAAAAG0AAAB7Il9fbmFtZV9fIjoicHJvbWV0aGV1c19odHRwX3JlcXVlc3RzX3RvdGFsIiwiY29kZSI6IjQwMCIsImhhbmRsZXIiOiIvYXBpL3YxL3F1ZXJ5X3JhbmdlIiwiam9iIjoicHJvbWV0aGV1cyJ9AAAABgAAAGxhYmVscwAAIP///wgAAACEAAAAeQAAAHsiZGlzcGxheU5hbWVGcm9tRFMiOiJwcm9tZXRoZXVzX2h0dHBfcmVxdWVzdHNfdG90YWx7Y29kZT1cIjQwMFwiLCBoYW5kbGVyPVwiL2FwaS92MS9xdWVyeV9yYW5nZVwiLCBqb2I9XCJwcm9tZXRoZXVzXCJ9In0AAAAGAAAAY29uZmlnAAAAAAAAiv///wAAAgAFAAAAVmFsdWUAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAATAAAAAAAAApMAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAcAMAAEFSUk9XMQ== diff --git a/pkg/tsdb/prometheus/testdata/range_simple.result.json b/pkg/tsdb/prometheus/testdata/range_simple.result.json new file mode 100644 index 0000000000000..3c510aca9a3d1 --- /dev/null +++ b/pkg/tsdb/prometheus/testdata/range_simple.result.json @@ -0,0 +1,34 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { + "__name__": "prometheus_http_requests_total", + "code": "200", + "handler": "/api/v1/query_range", + "job": "prometheus" + }, + "values": [ + [1641889530, "21"], + [1641889531, "32"], + [1641889532, "43"] + ] + }, + { + "metric": { + "__name__": "prometheus_http_requests_total", + "code": "400", + "handler": "/api/v1/query_range", + "job": "prometheus" + }, + "values": [ + [1641889530, "54"], + [1641889531, "65"], + [1641889532, "76"] + ] + } + ] + } +} diff --git a/pkg/tsdb/prometheus/time_series_query.go b/pkg/tsdb/prometheus/time_series_query.go index fbb2497cc8efe..38f40d5ab7f87 100644 --- a/pkg/tsdb/prometheus/time_series_query.go +++ b/pkg/tsdb/prometheus/time_series_query.go @@ -18,7 +18,7 @@ import ( "github.com/prometheus/common/model" ) -// Internal interval and range variables +//Internal interval and range variables const ( varInterval = "$__interval" varIntervalMs = "$__interval_ms" @@ -28,6 +28,17 @@ const ( varRateInterval = "$__rate_interval" ) +//Internal interval and range variables with {} syntax +//Repetitive code, we should have functionality to unify these +const ( + varIntervalAlt = "${__interval}" + varIntervalMsAlt = "${__interval_ms}" + varRangeAlt = "${__range}" + varRangeSAlt = "${__range_s}" + varRangeMsAlt = "${__range_ms}" + varRateIntervalAlt = "${__rate_interval}" +) + type TimeSeriesQueryType string const ( @@ -36,18 +47,11 @@ const ( ExemplarQueryType TimeSeriesQueryType = "exemplar" ) -func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo *DatasourceInfo) (*backend.QueryDataResponse, error) { - client := dsInfo.promClient - +func runQueries(ctx context.Context, client apiv1.API, queries []*PrometheusQuery, fillNulls bool) (*backend.QueryDataResponse, error) { result := backend.QueryDataResponse{ Responses: backend.Responses{}, } - queries, err := s.parseTimeSeriesQuery(req, dsInfo) - if err != nil { - return &result, err - } - for _, query := range queries { plog.Debug("Sending query", "start", query.Start, "end", query.End, "step", query.Step, "query", query.Expr) @@ -62,8 +66,8 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query timeRange := apiv1.Range{ Step: query.Step, // Align query range to step. It rounds start and end down to a multiple of step. - Start: time.Unix(int64(math.Floor((float64(query.Start.Unix()+query.UtcOffsetSec)/query.Step.Seconds()))*query.Step.Seconds()-float64(query.UtcOffsetSec)), 0), - End: time.Unix(int64(math.Floor((float64(query.End.Unix()+query.UtcOffsetSec)/query.Step.Seconds()))*query.Step.Seconds()-float64(query.UtcOffsetSec)), 0), + Start: alignTimeRange(query.Start, query.Step, query.UtcOffsetSec), + End: alignTimeRange(query.End, query.Step, query.UtcOffsetSec), } if query.RangeQuery { @@ -97,7 +101,7 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query } } - frames, err := parseTimeSeriesResponse(response, query) + frames, err := parseTimeSeriesResponse(response, query, fillNulls) if err != nil { return &result, err } @@ -110,6 +114,28 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query return &result, nil } +func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo *DatasourceInfo) (*backend.QueryDataResponse, error) { + client, err := dsInfo.getClient(req.Headers) + if err != nil { + return nil, err + } + + queries, err := s.parseTimeSeriesQuery(req, dsInfo) + if err != nil { + result := backend.QueryDataResponse{ + Responses: backend.Responses{}, + } + return &result, err + } + + fillNulls := true + if req.Headers["FromAlert"] == "true" { + fillNulls = false + } + + return runQueries(ctx, client, queries, fillNulls) +} + func formatLegend(metric model.Metric, query *PrometheusQuery) string { var legend string @@ -145,50 +171,14 @@ func (s *Service) parseTimeSeriesQuery(queryContext *backend.QueryDataRequest, d return nil, err } //Final interval value - var interval time.Duration - - //Calculate interval - queryInterval := model.Interval - //If we are using variable or interval/step, we will replace it with calculated interval - if queryInterval == varInterval || queryInterval == varIntervalMs || queryInterval == varRateInterval { - queryInterval = "" - } - minInterval, err := intervalv2.GetIntervalFrom(dsInfo.TimeInterval, queryInterval, model.IntervalMS, 15*time.Second) + interval, err := calculatePrometheusInterval(model, dsInfo, query, s.intervalCalculator) if err != nil { return nil, err } - calculatedInterval := s.intervalCalculator.Calculate(query.TimeRange, minInterval, query.MaxDataPoints) - safeInterval := s.intervalCalculator.CalculateSafeInterval(query.TimeRange, int64(safeRes)) - adjustedInterval := safeInterval.Value - - if calculatedInterval.Value > safeInterval.Value { - adjustedInterval = calculatedInterval.Value - } - - if queryInterval == varRateInterval { - // Rate interval is final and is not affected by resolution - interval = calculateRateInterval(adjustedInterval, dsInfo.TimeInterval, s.intervalCalculator) - } else { - intervalFactor := model.IntervalFactor - if intervalFactor == 0 { - intervalFactor = 1 - } - interval = time.Duration(int64(adjustedInterval) * intervalFactor) - } - - intervalMs := int64(interval / time.Millisecond) - rangeS := query.TimeRange.To.Unix() - query.TimeRange.From.Unix() - // Interpolate variables in expr - expr := model.Expr - expr = strings.ReplaceAll(expr, varIntervalMs, strconv.FormatInt(intervalMs, 10)) - expr = strings.ReplaceAll(expr, varInterval, intervalv2.FormatDuration(interval)) - expr = strings.ReplaceAll(expr, varRangeMs, strconv.FormatInt(rangeS*1000, 10)) - expr = strings.ReplaceAll(expr, varRangeS, strconv.FormatInt(rangeS, 10)) - expr = strings.ReplaceAll(expr, varRange, strconv.FormatInt(rangeS, 10)+"s") - expr = strings.ReplaceAll(expr, varRateInterval, intervalv2.FormatDuration(calculateRateInterval(interval, dsInfo.TimeInterval, s.intervalCalculator))) - + timeRange := query.TimeRange.To.Sub(query.TimeRange.From) + expr := interpolateVariables(model, interval, timeRange, s.intervalCalculator, dsInfo.TimeInterval) rangeQuery := model.RangeQuery if !model.InstantQuery && !model.RangeQuery { // In older dashboards, we were not setting range query param and !range && !instant was run as range query @@ -217,7 +207,7 @@ func (s *Service) parseTimeSeriesQuery(queryContext *backend.QueryDataRequest, d return qs, nil } -func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *PrometheusQuery) (data.Frames, error) { +func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *PrometheusQuery, fillNulls bool) (data.Frames, error) { var ( frames = data.Frames{} nextFrames = data.Frames{} @@ -229,7 +219,11 @@ func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *P switch v := value.(type) { case model.Matrix: - nextFrames = matrixToDataFrames(v, query, nextFrames) + if fillNulls { + nextFrames = matrixToDataFramesWithNullFill(v, query, nextFrames) + } else { + nextFrames = matrixToDataFrames(v, query, nextFrames) + } case model.Vector: nextFrames = vectorToDataFrames(v, query, nextFrames) case *model.Scalar: @@ -247,6 +241,38 @@ func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *P return frames, nil } +func calculatePrometheusInterval(model *QueryModel, dsInfo *DatasourceInfo, query backend.DataQuery, intervalCalculator intervalv2.Calculator) (time.Duration, error) { + queryInterval := model.Interval + + //If we are using variable for interval/step, we will replace it with calculated interval + if isVariableInterval(queryInterval) { + queryInterval = "" + } + + minInterval, err := intervalv2.GetIntervalFrom(dsInfo.TimeInterval, queryInterval, model.IntervalMS, 15*time.Second) + if err != nil { + return time.Duration(0), err + } + calculatedInterval := intervalCalculator.Calculate(query.TimeRange, minInterval, query.MaxDataPoints) + safeInterval := intervalCalculator.CalculateSafeInterval(query.TimeRange, int64(safeRes)) + + adjustedInterval := safeInterval.Value + if calculatedInterval.Value > safeInterval.Value { + adjustedInterval = calculatedInterval.Value + } + + if model.Interval == varRateInterval || model.Interval == varRateIntervalAlt { + // Rate interval is final and is not affected by resolution + return calculateRateInterval(adjustedInterval, dsInfo.TimeInterval, intervalCalculator), nil + } else { + intervalFactor := model.IntervalFactor + if intervalFactor == 0 { + intervalFactor = 1 + } + return time.Duration(int64(adjustedInterval) * intervalFactor), nil + } +} + func calculateRateInterval(interval time.Duration, scrapeInterval string, intervalCalculator intervalv2.Calculator) time.Duration { scrape := scrapeInterval if scrape == "" { @@ -262,19 +288,98 @@ func calculateRateInterval(interval time.Duration, scrapeInterval string, interv return rateInterval } -func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames { +func interpolateVariables(model *QueryModel, interval time.Duration, timeRange time.Duration, intervalCalculator intervalv2.Calculator, timeInterval string) string { + expr := model.Expr + rangeMs := timeRange.Milliseconds() + rangeSRounded := int64(math.Round(float64(rangeMs) / 1000.0)) + + var rateInterval time.Duration + if model.Interval == varRateInterval || model.Interval == varRateIntervalAlt { + rateInterval = interval + } else { + rateInterval = calculateRateInterval(interval, timeInterval, intervalCalculator) + } + + expr = strings.ReplaceAll(expr, varIntervalMs, strconv.FormatInt(int64(interval/time.Millisecond), 10)) + expr = strings.ReplaceAll(expr, varInterval, intervalv2.FormatDuration(interval)) + expr = strings.ReplaceAll(expr, varRangeMs, strconv.FormatInt(rangeMs, 10)) + expr = strings.ReplaceAll(expr, varRangeS, strconv.FormatInt(rangeSRounded, 10)) + expr = strings.ReplaceAll(expr, varRange, strconv.FormatInt(rangeSRounded, 10)+"s") + expr = strings.ReplaceAll(expr, varRateInterval, rateInterval.String()) + + // Repetitive code, we should have functionality to unify these + expr = strings.ReplaceAll(expr, varIntervalMsAlt, strconv.FormatInt(int64(interval/time.Millisecond), 10)) + expr = strings.ReplaceAll(expr, varIntervalAlt, intervalv2.FormatDuration(interval)) + expr = strings.ReplaceAll(expr, varRangeMsAlt, strconv.FormatInt(rangeMs, 10)) + expr = strings.ReplaceAll(expr, varRangeSAlt, strconv.FormatInt(rangeSRounded, 10)) + expr = strings.ReplaceAll(expr, varRangeAlt, strconv.FormatInt(rangeSRounded, 10)+"s") + expr = strings.ReplaceAll(expr, varRateIntervalAlt, rateInterval.String()) + return expr +} + +func matrixToDataFramesWithNullFill(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames { for _, v := range matrix { tags := make(map[string]string, len(v.Metric)) for k, v := range v.Metric { tags[string(k)] = string(v) } + baseTimestamp := alignTimeRange(query.Start, query.Step, query.UtcOffsetSec).UnixMilli() + endTimestamp := alignTimeRange(query.End, query.Step, query.UtcOffsetSec).UnixMilli() + // For each step we create 1 data point. This results in range / step + 1 data points. + datapointsCount := int((endTimestamp-baseTimestamp)/query.Step.Milliseconds()) + 1 + + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, datapointsCount) + valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, datapointsCount) + idx := 0 + + for _, pair := range v.Values { + timestamp := int64(pair.Timestamp) + value := float64(pair.Value) + + for t := baseTimestamp; t < timestamp; t += query.Step.Milliseconds() { + timeField.Set(idx, time.Unix(0, t*1_000_000).UTC()) + idx++ + } + + timeField.Set(idx, pair.Timestamp.Time().UTC()) + if !math.IsNaN(value) { + valueField.Set(idx, &value) + } + baseTimestamp = timestamp + query.Step.Milliseconds() + idx++ + } + + for t := baseTimestamp; t <= endTimestamp; t += query.Step.Milliseconds() { + timeField.Set(idx, time.Unix(0, t*1_000_000).UTC()) + idx++ + } + + name := formatLegend(v.Metric, query) + timeField.Name = data.TimeSeriesTimeFieldName + valueField.Name = data.TimeSeriesValueFieldName + valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} + valueField.Labels = tags + + frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) + } + + return frames +} + +func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames { + for _, v := range matrix { + tags := make(map[string]string, len(v.Metric)) + for k, v := range v.Metric { + tags[string(k)] = string(v) + } timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(v.Values)) valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(v.Values)) for i, k := range v.Values { - timeField.Set(i, time.Unix(k.Timestamp.Unix(), 0).UTC()) + timeField.Set(i, k.Timestamp.Time().UTC()) value := float64(k.Value) + if !math.IsNaN(value) { valueField.Set(i, &value) } @@ -285,7 +390,6 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data valueField.Name = data.TimeSeriesValueFieldName valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} valueField.Labels = tags - frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) } @@ -293,7 +397,7 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data } func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery, frames data.Frames) data.Frames { - timeVector := []time.Time{time.Unix(scalar.Timestamp.Unix(), 0).UTC()} + timeVector := []time.Time{scalar.Timestamp.Time().UTC()} values := []float64{float64(scalar.Value)} name := fmt.Sprintf("%g", values[0]) @@ -312,7 +416,7 @@ func vectorToDataFrames(vector model.Vector, query *PrometheusQuery, frames data for _, v := range vector { name := formatLegend(v.Metric, query) tags := make(map[string]string, len(v.Metric)) - timeVector := []time.Time{time.Unix(v.Timestamp.Unix(), 0).UTC()} + timeVector := []time.Time{v.Timestamp.Time().UTC()} values := []float64{float64(v.Value)} for k, v := range v.Metric { @@ -341,7 +445,7 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu for _, exemplarData := range response { for _, exemplar := range exemplarData.Exemplars { event := ExemplarEvent{} - exemplarTime := time.Unix(exemplar.Timestamp.Unix(), 0).UTC() + exemplarTime := exemplar.Timestamp.Time().UTC() event.Time = exemplarTime event.Value = float64(exemplar.Value) event.Labels = make(map[string]string) @@ -473,3 +577,18 @@ func newDataFrame(name string, typ string, fields ...*data.Field) *data.Frame { return frame } + +func alignTimeRange(t time.Time, step time.Duration, offset int64) time.Time { + return time.Unix(int64(math.Floor((float64(t.Unix()+offset)/step.Seconds()))*step.Seconds()-float64(offset)), 0) +} + +func isVariableInterval(interval string) bool { + if interval == varInterval || interval == varIntervalMs || interval == varRateInterval { + return true + } + //Repetitive code, we should have functionality to unify these + if interval == varIntervalAlt || interval == varIntervalMsAlt || interval == varRateIntervalAlt { + return true + } + return false +} diff --git a/pkg/tsdb/prometheus/time_series_query_test.go b/pkg/tsdb/prometheus/time_series_query_test.go index 30360f7ea17db..0ca855f002681 100644 --- a/pkg/tsdb/prometheus/time_series_query_test.go +++ b/pkg/tsdb/prometheus/time_series_query_test.go @@ -205,6 +205,25 @@ func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", models[0].Expr) }) + t.Run("parsing query model with ${__interval} variable", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(48 * time.Hour), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [${__interval}]})", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [2m]})", models[0].Expr) + }) + t.Run("parsing query model with $__interval_ms variable", func(t *testing.T) { timeRange := backend.TimeRange{ From: now, @@ -243,6 +262,25 @@ func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", models[0].Expr) }) + t.Run("parsing query model with ${__interval_ms} and ${__interval} variable", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(48 * time.Hour), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [${__interval_ms}]}) + rate(ALERTS{job=\"test\" [${__interval}]})", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [120000]}) + rate(ALERTS{job=\"test\" [2m]})", models[0].Expr) + }) + t.Run("parsing query model with $__range variable", func(t *testing.T) { timeRange := backend.TimeRange{ From: now, @@ -281,6 +319,63 @@ func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { require.Equal(t, "rate(ALERTS{job=\"test\" [172800]})", models[0].Expr) }) + t.Run("parsing query model with ${__range_s} variable", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(48 * time.Hour), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [${__range_s}s]})", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [172800s]})", models[0].Expr) + }) + + t.Run("parsing query model with $__range_s variable below 0.5s", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(40 * time.Millisecond), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [$__range_s]})", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [0]})", models[0].Expr) + }) + + t.Run("parsing query model with $__range_s variable between 1-0.5s", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(800 * time.Millisecond), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [$__range_s]})", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [1]})", models[0].Expr) + }) + t.Run("parsing query model with $__range_ms variable", func(t *testing.T) { timeRange := backend.TimeRange{ From: now, @@ -300,6 +395,25 @@ func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { require.Equal(t, "rate(ALERTS{job=\"test\" [172800000]})", models[0].Expr) }) + t.Run("parsing query model with $__range_ms variable below 1s", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(20 * time.Millisecond), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [$__range_ms]})", + "format": "time_series", + "intervalFactor": 1, + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [20]})", models[0].Expr) + }) + t.Run("parsing query model with $__rate_interval variable", func(t *testing.T) { timeRange := backend.TimeRange{ From: now, @@ -310,13 +424,35 @@ func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { "expr": "rate(ALERTS{job=\"test\" [$__rate_interval]})", "format": "time_series", "intervalFactor": 1, + "interval": "5m", + "refId": "A" + }`, timeRange) + + dsInfo := &DatasourceInfo{} + models, err := service.parseTimeSeriesQuery(query, dsInfo) + require.NoError(t, err) + require.Equal(t, "rate(ALERTS{job=\"test\" [5m15s]})", models[0].Expr) + }) + + t.Run("parsing query model with $__rate_interval variable in expr and interval", func(t *testing.T) { + timeRange := backend.TimeRange{ + From: now, + To: now.Add(5 * time.Minute), + } + + query := queryContext(`{ + "expr": "rate(ALERTS{job=\"test\" [$__rate_interval]})", + "format": "time_series", + "intervalFactor": 1, + "interval": "$__rate_interval", "refId": "A" }`, timeRange) dsInfo := &DatasourceInfo{} models, err := service.parseTimeSeriesQuery(query, dsInfo) require.NoError(t, err) - require.Equal(t, "rate(ALERTS{job=\"test\" [1m]})", models[0].Expr) + require.Equal(t, "rate(ALERTS{job=\"test\" [1m0s]})", models[0].Expr) + require.Equal(t, 1*time.Minute, models[0].Step) }) t.Run("parsing query model of range query", func(t *testing.T) { @@ -453,6 +589,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } query := &PrometheusQuery{ LegendFormat: "legend {{app}}", + Step: 1 * time.Second, + Start: time.Unix(1, 0).UTC(), + End: time.Unix(5, 0).UTC(), + UtcOffsetSec: 0, } res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) @@ -472,6 +612,37 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { require.Equal(t, "UTC", testValue.(time.Time).Location().String()) }) + t.Run("matrix response with missed data points should be parsed correctly", func(t *testing.T) { + values := []p.SamplePair{ + {Value: 1, Timestamp: 1000}, + {Value: 4, Timestamp: 4000}, + } + value := make(map[TimeSeriesQueryType]interface{}) + value[RangeQueryType] = p.Matrix{ + &p.SampleStream{ + Metric: p.Metric{"app": "Application", "tag2": "tag2"}, + Values: values, + }, + } + query := &PrometheusQuery{ + LegendFormat: "", + Step: 1 * time.Second, + Start: time.Unix(1, 0).UTC(), + End: time.Unix(4, 0).UTC(), + UtcOffsetSec: 0, + } + res, err := parseTimeSeriesResponse(value, query) + + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, res[0].Fields[0].Len(), 4) + require.Equal(t, res[0].Fields[0].At(1), time.Unix(2, 0).UTC()) + require.Equal(t, res[0].Fields[0].At(2), time.Unix(3, 0).UTC()) + require.Equal(t, res[0].Fields[1].Len(), 4) + require.Nil(t, res[0].Fields[1].At(1)) + require.Nil(t, res[0].Fields[1].At(2)) + }) + t.Run("matrix response with NaN value should be changed to null", func(t *testing.T) { value := make(map[TimeSeriesQueryType]interface{}) value[RangeQueryType] = p.Matrix{ @@ -484,6 +655,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } query := &PrometheusQuery{ LegendFormat: "", + Step: 1 * time.Second, + Start: time.Unix(1, 0).UTC(), + End: time.Unix(4, 0).UTC(), + UtcOffsetSec: 0, } res, err := parseTimeSeriesResponse(value, query) require.NoError(t, err) @@ -499,7 +674,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { &p.Sample{ Metric: p.Metric{"app": "Application", "tag2": "tag2"}, Value: 1, - Timestamp: 1000, + Timestamp: 123, }, } query := &PrometheusQuery{ @@ -522,13 +697,14 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { // Ensure the timestamps are UTC zoned testValue := res[0].Fields[0].At(0) require.Equal(t, "UTC", testValue.(time.Time).Location().String()) + require.Equal(t, int64(123), testValue.(time.Time).UnixMilli()) }) t.Run("scalar response should be parsed normally", func(t *testing.T) { value := make(map[TimeSeriesQueryType]interface{}) value[RangeQueryType] = &p.Scalar{ Value: 1, - Timestamp: 1000, + Timestamp: 123, } query := &PrometheusQuery{} @@ -546,6 +722,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { // Ensure the timestamps are UTC zoned testValue := res[0].Fields[0].At(0) require.Equal(t, "UTC", testValue.(time.Time).Location().String()) + require.Equal(t, int64(123), testValue.(time.Time).UnixMilli()) }) } diff --git a/pkg/tsdb/prometheus/types.go b/pkg/tsdb/prometheus/types.go index 6841f25fafc0d..92784411b1364 100644 --- a/pkg/tsdb/prometheus/types.go +++ b/pkg/tsdb/prometheus/types.go @@ -11,9 +11,11 @@ type DatasourceInfo struct { URL string TimeInterval string - promClient apiv1.API + getClient clientGetter } +type clientGetter func(map[string]string) (apiv1.API, error) + type PrometheusQuery struct { Expr string Step time.Duration diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index 7cc69daf46059..fbfb933c8e174 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -258,7 +258,7 @@ func (e *DataSourceHandler) executeQuery(query backend.DataQuery, wg *sync.WaitG defer session.Close() db := session.DB() - rows, err := db.Query(interpolatedQuery) + rows, err := db.QueryContext(queryContext, interpolatedQuery) if err != nil { errAppendDebug("db query error", e.transformQueryError(err), interpolatedQuery) return diff --git a/pkg/tsdb/testdatasource/csv_data.go b/pkg/tsdb/testdatasource/csv_data.go index 8154695db0596..6cb812e0ee596 100644 --- a/pkg/tsdb/testdatasource/csv_data.go +++ b/pkg/tsdb/testdatasource/csv_data.go @@ -73,13 +73,14 @@ func (s *Service) handleCsvFileScenario(ctx context.Context, req *backend.QueryD } func (s *Service) loadCsvFile(fileName string) (*data.Frame, error) { - validFileName := regexp.MustCompile(`([\w_]+)\.csv`) + validFileName := regexp.MustCompile(`^\w+\.csv$`) if !validFileName.MatchString(fileName) { return nil, fmt.Errorf("invalid csv file name: %q", fileName) } - filePath := filepath.Join(s.cfg.StaticRootPath, "testdata", fileName) + csvFilepath := filepath.Clean(filepath.Join("/", fileName)) + filePath := filepath.Join(s.cfg.StaticRootPath, "testdata", csvFilepath) // Can ignore gosec G304 here, because we check the file pattern above // nolint:gosec diff --git a/pkg/util/proxyutil/proxyutil.go b/pkg/util/proxyutil/proxyutil.go index d6e3572133370..3db22a1426ed1 100644 --- a/pkg/util/proxyutil/proxyutil.go +++ b/pkg/util/proxyutil/proxyutil.go @@ -42,3 +42,9 @@ func ClearCookieHeader(req *http.Request, keepCookiesNames []string) { req.AddCookie(c) } } + +// SetProxyResponseHeaders sets proxy response headers. +// Sets Content-Security-Policy: sandbox +func SetProxyResponseHeaders(header http.Header) { + header.Set("Content-Security-Policy", "sandbox") +} diff --git a/plugins-bundled/internal/input-datasource/package.json b/plugins-bundled/internal/input-datasource/package.json index a58202216cbc9..eeec39687c202 100644 --- a/plugins-bundled/internal/input-datasource/package.json +++ b/plugins-bundled/internal/input-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@grafana-plugins/input-datasource", - "version": "8.3.0-pre", + "version": "8.3.10", "description": "Input Datasource", "private": true, "repository": { @@ -16,14 +16,17 @@ "author": "Grafana Labs", "devDependencies": { "@types/jest": "26.0.15", + "@types/lodash": "4.14.149", "@types/react": "17.0.30", + "lodash": "4.17.21", + "pnp-webpack-plugin": "^1.7.0", "ts-loader": "8.0.11", "webpack": "5.58.1" }, "dependencies": { - "@grafana/data": "8.3.0-pre", - "@grafana/toolkit": "8.3.0-pre", - "@grafana/ui": "8.3.0-pre", + "@grafana/data": "8.3.10", + "@grafana/toolkit": "8.3.10", + "@grafana/ui": "8.3.10", "jquery": "3.5.1", "react": "17.0.1", "react-dom": "17.0.1", diff --git a/plugins-bundled/internal/input-datasource/webpack.config.js b/plugins-bundled/internal/input-datasource/webpack.config.js new file mode 100644 index 0000000000000..50dfc450d1bf5 --- /dev/null +++ b/plugins-bundled/internal/input-datasource/webpack.config.js @@ -0,0 +1,15 @@ +const { merge } = require('lodash'); +const PnpWebpackPlugin = require('pnp-webpack-plugin'); + +module.exports = { + getWebpackConfig: (baseConfig) => { + return merge(baseConfig, { + resolve: { + plugins: [PnpWebpackPlugin], + }, + resolveLoader: { + plugins: [PnpWebpackPlugin.moduleLoader(module)], + }, + }); + }, +}; diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 63ff476ebe331..d4eb79c4990ff 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -1,9 +1,10 @@ import React, { ComponentType } from 'react'; import { Router, Route, Redirect, Switch } from 'react-router-dom'; import { config, locationService, navigationLogger } from '@grafana/runtime'; +import { TourProvider } from '@reactour/tour'; import { Provider } from 'react-redux'; import { store } from 'app/store/store'; -import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider } from '@grafana/ui'; +import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider, getTheme } from '@grafana/ui'; import { GrafanaApp } from './app'; import { getAppRoutes } from 'app/routes/routes'; import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider'; @@ -16,6 +17,13 @@ import { AppNotificationList } from './core/components/AppNotifications/AppNotif import { SearchWrapper } from 'app/features/search'; import { LiveConnectionWarning } from './features/live/LiveConnectionWarning'; import { AngularRoot } from './angular/AngularRoot'; +import { PerconaBootstrapper } from 'app/percona/shared/components/PerconaBootstrapper'; +import getSteps from './tour/steps'; +import Close from './tour/Close'; +import Navigation from './tour/Navigation'; +import { PERCONA_TOUR_FLAG } from './tour/constants'; +import { isPmmAdmin } from './percona/shared/helpers/permissions'; +import { WebSocketProvider } from './percona/shared/websockets/WebSocketProvider'; interface AppWrapperProps { app: GrafanaApp; @@ -102,20 +110,42 @@ export class AppWrapper extends React.Component
- {newNavigationEnabled ? : } -
- {pageBanners.map((Banner, index) => ( - - ))} - - - - - {this.state.ngInjector && this.renderRoutes()} - {bodyRenderHooks.map((Hook, index) => ( - - ))} -
+ + `${currentStep + 1}/${totalSteps}`} + disableFocusLock + onClickClose={({ setIsOpen }) => { + localStorage.setItem(PERCONA_TOUR_FLAG, 'false'); + setIsOpen(false); + }} + className="pmm-tour" + styles={{ + popover: (base) => ({ + ...base, + backgroundColor: getTheme(config.bootData.user.lightTheme ? 'light' : 'dark').colors.bg1, + }), + }} + > + + {newNavigationEnabled ? : } +
+ {pageBanners.map((Banner, index) => ( + + ))} + + + + + {this.state.ngInjector && this.renderRoutes()} + {bodyRenderHooks.map((Hook, index) => ( + + ))} +
+
+
diff --git a/public/app/angular/misc.ts b/public/app/angular/misc.ts index dec5cd26ff00e..bd31ad80fdf36 100644 --- a/public/app/angular/misc.ts +++ b/public/app/angular/misc.ts @@ -1,8 +1,5 @@ import angular from 'angular'; -import Clipboard from 'clipboard'; import coreModule from './core_module'; -import { appEvents } from 'app/core/core'; -import { AppEvents } from '@grafana/data'; /** @ngInject */ function tip($compile: any) { @@ -23,31 +20,6 @@ function tip($compile: any) { }; } -function clipboardButton() { - return { - scope: { - getText: '&clipboardButton', - }, - link: (scope: any, elem: any) => { - scope.clipboard = new Clipboard(elem[0], { - text: () => { - return scope.getText(); - }, - }); - - scope.clipboard.on('success', () => { - appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); - }); - - scope.$on('$destroy', () => { - if (scope.clipboard) { - scope.clipboard.destroy(); - } - }); - }, - }; -} - /** @ngInject */ function compile($compile: any) { return { @@ -209,7 +181,6 @@ function gfDropdown($parse: any, $compile: any, $timeout: any) { } coreModule.directive('tip', tip); -coreModule.directive('clipboardButton', clipboardButton); coreModule.directive('compile', compile); coreModule.directive('watchChange', watchChange); coreModule.directive('editorOptBool', editorOptBool); diff --git a/public/app/app.ts b/public/app/app.ts index 721268ef29a90..9561c683cfdac 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -33,7 +33,6 @@ import { setDataSourceSrv, setEchoSrv, setLocationSrv, - setPanelRenderer, setQueryRunnerFactory, } from '@grafana/runtime'; import { Echo } from './core/services/echo/Echo'; @@ -60,6 +59,9 @@ import { ApplicationInsightsBackend } from './core/services/echo/backends/analyt import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend'; import { getAllOptionEditors } from './core/components/editors/registry'; import { backendSrv } from './core/services/backend_srv'; +import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; +import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; +import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { DatasourceSrv } from './features/plugins/datasource_srv'; import { AngularApp } from './angular'; import { ModalManager } from './core/services/ModalManager'; @@ -93,11 +95,13 @@ export class GrafanaApp { setLocale(config.bootData.user.locale); setWeekStart(config.bootData.user.weekStart); setPanelRenderer(PanelRenderer); + setPanelDataErrorView(PanelDataErrorView); setLocationSrv(locationService); setTimeZoneResolver(() => config.bootData.user.timezone); - // Important that extensions are initialized before store - initExtensions(); + // Important that extension reducers are initialized before store + addExtensionReducers(); configureStore(); + initExtensions(); standardEditorsRegistry.setInit(getAllOptionEditors); standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); @@ -145,6 +149,12 @@ export class GrafanaApp { } } +function addExtensionReducers() { + if (extensionsExports.length > 0) { + extensionsExports[0].addExtensionReducers(); + } +} + function initExtensions() { if (extensionsExports.length > 0) { extensionsExports[0].init(); diff --git a/public/app/core/__mocks__/app_events.ts b/public/app/core/__mocks__/app_events.ts new file mode 100644 index 0000000000000..c4c45ac4055f5 --- /dev/null +++ b/public/app/core/__mocks__/app_events.ts @@ -0,0 +1,3 @@ +export const appEvents = { emit: jest.fn() }; + +export default appEvents; diff --git a/public/app/core/components/Branding/Branding.tsx b/public/app/core/components/Branding/Branding.tsx index 15035264f152d..3f8252daa56e4 100644 --- a/public/app/core/components/Branding/Branding.tsx +++ b/public/app/core/components/Branding/Branding.tsx @@ -9,7 +9,7 @@ export interface BrandComponentProps { } const LoginLogo: FC = ({ className }) => { - return Grafana; + return PMM; }; const LoginBackground: FC = ({ className, children }) => { @@ -42,7 +42,7 @@ const LoginBackground: FC = ({ className, children }) => { }; const MenuLogo: FC = ({ className }) => { - return Grafana; + return PMM; }; const LoginBoxBackground = () => { @@ -58,9 +58,16 @@ export class Branding { static LoginBackground = LoginBackground; static MenuLogo = MenuLogo; static LoginBoxBackground = LoginBoxBackground; - static AppTitle = 'Grafana'; - static LoginTitle = 'Welcome to Grafana'; - static GetLoginSubTitle = (): null | string => { - return null; + static AppTitle = 'Percona Monitoring and Management'; + static LoginTitle = 'Percona Monitoring and Management'; + static GetLoginSubTitle = () => { + const slogans = [ + "Don't get in the way of the data", + 'Your single pane of glass', + 'Built better together', + 'Democratising data', + ]; + const count = slogans.length; + return slogans[Math.floor(Math.random() * count)]; }; } diff --git a/public/app/core/components/CardButton.tsx b/public/app/core/components/CardButton.tsx new file mode 100644 index 0000000000000..45d87b600c4ae --- /dev/null +++ b/public/app/core/components/CardButton.tsx @@ -0,0 +1,52 @@ +import React, { HTMLAttributes } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, IconName, useStyles2 } from '@grafana/ui'; + +interface Props extends HTMLAttributes { + icon: IconName; + onClick: () => void; + children: React.ReactNode; +} + +export const CardButton = React.forwardRef( + ({ icon, children, onClick, ...restProps }, ref) => { + const styles = useStyles2(getStyles); + + return ( + + ); + } +); + +CardButton.displayName = 'CardButton'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + action: css` + display: flex; + flex-direction: column; + height: 100%; + + justify-self: center; + cursor: pointer; + background: ${theme.colors.background.secondary}; + border-radius: ${theme.shape.borderRadius(1)}; + color: ${theme.colors.text.primary}; + border: unset; + width: 100%; + display: flex; + + justify-content: center; + align-items: center; + text-align: center; + + &:hover { + background: ${theme.colors.emphasize(theme.colors.background.secondary)}; + } + `, + }; +}; diff --git a/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx deleted file mode 100644 index 0b618d09d9fbb..0000000000000 --- a/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { PureComponent, ReactNode } from 'react'; -import ClipboardJS from 'clipboard'; - -interface Props { - text: () => string; - elType?: string | React.ForwardRefExoticComponent; - onSuccess?: (evt: any) => void; - onError?: (evt: any) => void; - className?: string; - children?: ReactNode; -} - -export class CopyToClipboard extends PureComponent { - clipboardjs?: ClipboardJS; - myRef: any; - - constructor(props: Props) { - super(props); - this.myRef = React.createRef(); - } - - componentDidMount() { - this.initClipboardJS(); - } - - componentDidUpdate() { - if (this.clipboardjs) { - this.clipboardjs.destroy(); - } - this.initClipboardJS(); - } - - initClipboardJS = () => { - const { text, onSuccess, onError } = this.props; - - this.clipboardjs = new ClipboardJS(this.myRef.current, { - text: text, - }); - - if (onSuccess) { - this.clipboardjs.on('success', (evt) => { - evt.clearSelection(); - onSuccess(evt); - }); - } - - if (onError) { - this.clipboardjs.on('error', (evt) => { - console.error('Action:', evt.action); - console.error('Trigger:', evt.trigger); - onError(evt); - }); - } - }; - - componentWillUnmount() { - if (this.clipboardjs) { - this.clipboardjs.destroy(); - } - } - - getElementType = () => { - return this.props.elType || 'button'; - }; - - render() { - const { elType, text, children, onError, onSuccess, ...restProps } = this.props; - - return React.createElement( - this.getElementType(), - { - ref: this.myRef, - ...restProps, - }, - this.props.children - ); - } -} diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx index 5b7be702bd2e7..58b50ee2ab0b6 100644 --- a/public/app/core/components/Footer/Footer.tsx +++ b/public/app/core/components/Footer/Footer.tsx @@ -4,6 +4,7 @@ import { Icon, IconName } from '@grafana/ui'; export interface FooterLink { text: string; + id?: string; icon?: string; url?: string; target?: string; @@ -11,22 +12,29 @@ export interface FooterLink { export let getFooterLinks = (): FooterLink[] => { return [ + { + text: 'PMM Logs', + icon: 'download-alt', + url: '/logs.zip', + target: '_blank', + }, { text: 'Documentation', icon: 'document-info', - url: 'https://grafana.com/docs/grafana/latest/?utm_source=grafana_footer', + url: 'https://www.percona.com/doc/percona-monitoring-and-management/2.x/index.html?utm_source=pmm_footer', target: '_blank', }, { text: 'Support', icon: 'question-circle', - url: 'https://grafana.com/products/enterprise/?utm_source=grafana_footer', + url: 'https://www.percona.com/services/support?utm_source=pmm_footer', target: '_blank', }, { text: 'Community', icon: 'comments-alt', - url: 'https://community.grafana.com/?utm_source=grafana_footer', + url: + 'https://forums.percona.com/c/percona-monitoring-and-management-pmm/percona-monitoring-and-management-pmm-v2?utm_source=pmm_footer', target: '_blank', }, ]; @@ -47,6 +55,7 @@ export let getVersionLinks = (): FooterLink[] => { if (buildInfo.hasUpdate) { links.push({ + id: 'updateVersion', text: `New version available!`, icon: 'download-alt', url: 'https://grafana.com/grafana/download?utm_source=grafana_footer', @@ -66,7 +75,7 @@ export function setVersionLinkFn(fn: typeof getFooterLinks) { } export const Footer: FC = React.memo(() => { - const links = getFooterLinks().concat(getVersionLinks()); + const links = getFooterLinks(); return (
@@ -74,7 +83,7 @@ export const Footer: FC = React.memo(() => {
    {links.map((link) => (
  • - + {link.icon && } {link.text}
  • diff --git a/public/app/core/components/Footer/LoginFooter.tsx b/public/app/core/components/Footer/LoginFooter.tsx new file mode 100644 index 0000000000000..cc7b4a7cc131d --- /dev/null +++ b/public/app/core/components/Footer/LoginFooter.tsx @@ -0,0 +1,50 @@ +/* eslint-disable react/display-name */ +import React, { FC } from 'react'; + +interface FooterLink { + text: string; + url?: string; + target?: string; +} + +const FOOTER_LINKS: FooterLink[] = [ + { + text: 'Grafana', + url: 'https://grafana.com/grafana', + target: '_blank', + }, + { + text: 'Prometheus', + url: 'https://prometheus.io', + target: '_blank', + }, + { + text: 'Clickhouse', + url: 'https://clickhouse.tech', + target: '_blank', + }, + { + text: 'PostgreSQL', + url: 'https://www.postgresql.org', + target: '_blank', + }, +]; + +export const LoginFooter: FC = React.memo(() => { + return ( +
    +
    +
    Percona Monitoring and Management proudly powered by open source projects
    + +
    +
    + ); +}); diff --git a/public/app/core/components/Login/LoginLayout.tsx b/public/app/core/components/Login/LoginLayout.tsx index fd7261d1c10c5..b1c80034562c8 100644 --- a/public/app/core/components/Login/LoginLayout.tsx +++ b/public/app/core/components/Login/LoginLayout.tsx @@ -3,7 +3,7 @@ import { cx, css, keyframes } from '@emotion/css'; import { useStyles2, styleMixins } from '@grafana/ui'; import { Branding } from '../Branding/Branding'; import { GrafanaTheme2 } from '@grafana/data'; -import { Footer } from '../Footer/Footer'; +import { LoginFooter } from '../Footer/LoginFooter'; interface InnerBoxProps { enterAnimation?: boolean; @@ -15,7 +15,6 @@ export const InnerBox: FC = ({ children, enterAnimation = true }) export const LoginLayout: FC = ({ children }) => { const loginStyles = useStyles2(getLoginStyles); - const subTitle = Branding.GetLoginSubTitle(); const [startAnim, setStartAnim] = useState(false); useEffect(() => setStartAnim(true), []); @@ -27,12 +26,11 @@ export const LoginLayout: FC = ({ children }) => {

    {Branding.LoginTitle}

    - {subTitle &&

    {Branding.GetLoginSubTitle()}

    }
{children}
-
+ ); }; @@ -53,7 +51,7 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { return { container: css({ - minHeight: '100vh', + minHeight: '100%', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', backgroundColor: bgColor, diff --git a/public/app/core/components/NavBar/NavBar.tsx b/public/app/core/components/NavBar/NavBar.tsx index c23ab43fa55af..3a291fc3f85ac 100644 --- a/public/app/core/components/NavBar/NavBar.tsx +++ b/public/app/core/components/NavBar/NavBar.tsx @@ -1,18 +1,42 @@ import React, { FC, useState } from 'react'; -import { useLocation } from 'react-router-dom'; import { css, cx } from '@emotion/css'; -import { cloneDeep } from 'lodash'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; -import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { locationService } from '@grafana/runtime'; +import { Icon, IconName, useTheme2 } from '@grafana/ui'; +import { updateNavIndex } from 'app/core/actions'; import { Branding } from 'app/core/components/Branding/Branding'; import config from 'app/core/config'; +import { getPerconaSettings, getPerconaUser } from 'app/percona/shared/core/selectors'; import { KioskMode } from 'app/types'; -import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils'; +import { cloneDeep } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { OrgSwitcher } from '../OrgSwitcher'; +import { + getPmmSettingsPage, + PMM_ADD_INSTANCE_PAGE, + PMM_ALERTING_PAGE, + PMM_BACKUP_PAGE, + PMM_DBAAS_PAGE, + PMM_ENTITLEMENTS_PAGE, + PMM_ENVIRONMENT_OVERVIEW_PAGE, + PMM_INVENTORY_PAGE, + PMM_STT_PAGE, + PMM_TICKETS_PAGE, +} from './constants'; import NavBarItem from './NavBarItem'; -import { NavBarSection } from './NavBarSection'; +import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarMenu } from './NavBarMenu'; +import { NavBarSection } from './NavBarSection'; +import { + buildIntegratedAlertingMenuItem, + buildInventoryAndSettings, + enrichConfigItems, + getActiveItem, + isMatchOrChildMatch, + isSearchActive, + SEARCH_ITEM_ID, +} from './utils'; const homeUrl = config.appSubUrl || '/'; @@ -24,12 +48,18 @@ const searchItem: NavModelItem = { id: SEARCH_ITEM_ID, onClick: onOpenSearch, text: 'Search dashboards', + icon: 'search', }; export const NavBar: FC = React.memo(() => { const theme = useTheme2(); const styles = getStyles(theme); const location = useLocation(); + const dispatch = useDispatch(); + const { result } = useSelector(getPerconaSettings); + const { isAuthorized } = useSelector(getPerconaUser); + const { isPlatformUser } = useSelector(getPerconaUser); + const { sttEnabled, alertingEnabled, dbaasEnabled, backupEnabled } = result!; const query = new URLSearchParams(location.search); const kiosk = query.get('kiosk') as KioskMode; const [showSwitcherModal, setShowSwitcherModal] = useState(false); @@ -47,6 +77,43 @@ export const NavBar: FC = React.memo(() => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + dispatch(updateNavIndex(getPmmSettingsPage(alertingEnabled))); + dispatch(updateNavIndex(PMM_ALERTING_PAGE)); + dispatch(updateNavIndex(PMM_STT_PAGE)); + dispatch(updateNavIndex(PMM_DBAAS_PAGE)); + dispatch(updateNavIndex(PMM_BACKUP_PAGE)); + dispatch(updateNavIndex(PMM_INVENTORY_PAGE)); + dispatch(updateNavIndex(PMM_ADD_INSTANCE_PAGE)); + dispatch(updateNavIndex(PMM_TICKETS_PAGE)); + dispatch(updateNavIndex(PMM_ENTITLEMENTS_PAGE)); + dispatch(updateNavIndex(PMM_ENVIRONMENT_OVERVIEW_PAGE)); + + if (isPlatformUser) { + topItems.push(PMM_ENTITLEMENTS_PAGE); + topItems.push(PMM_TICKETS_PAGE); + topItems.push(PMM_ENVIRONMENT_OVERVIEW_PAGE); + } + + if (isAuthorized) { + buildInventoryAndSettings(topItems); + + if (alertingEnabled) { + buildIntegratedAlertingMenuItem(topItems); + } + + if (sttEnabled) { + topItems.push(PMM_STT_PAGE); + } + + if (dbaasEnabled) { + topItems.push(PMM_DBAAS_PAGE); + } + + if (backupEnabled) { + topItems.push(PMM_BACKUP_PAGE); + } + } + if (kiosk !== null) { return null; } @@ -58,15 +125,10 @@ export const NavBar: FC = React.memo(() => {
- + - - + + @@ -76,10 +138,7 @@ export const NavBar: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} @@ -94,13 +153,8 @@ export const NavBar: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} diff --git a/public/app/core/components/NavBar/NavBarItem.test.tsx b/public/app/core/components/NavBar/NavBarItem.test.tsx index 402b2a589f7c9..1103f209be512 100644 --- a/public/app/core/components/NavBar/NavBarItem.test.tsx +++ b/public/app/core/components/NavBar/NavBarItem.test.tsx @@ -1,55 +1,203 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BrowserRouter } from 'react-router-dom'; -import NavBarItem from './NavBarItem'; +import { locationUtil } from '@grafana/data'; +import { config, setLocationService } from '@grafana/runtime'; + +import NavBarItem, { Props } from './NavBarItem'; + +const onClickMock = jest.fn(); +const defaults: Props = { + children: undefined, + link: { + text: 'Parent Node', + onClick: onClickMock, + children: [ + { text: 'Child Node 1', onClick: onClickMock, children: [] }, + { text: 'Child Node 2', onClick: onClickMock, children: [] }, + ], + }, +}; + +function getTestContext(overrides: Partial = {}, subUrl = '') { + jest.clearAllMocks(); + config.appSubUrl = subUrl; + locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() }); + const pushMock = jest.fn(); + const locationService: any = { push: pushMock }; + setLocationService(locationService); + const props = { ...defaults, ...overrides }; + + const { rerender } = render( + + {props.children} + + ); + + return { rerender, pushMock }; +} describe('NavBarItem', () => { - it('renders the children', () => { - const mockLabel = 'Hello'; - render( - - -
- - - ); - - const child = screen.getByTestId('mockChild'); - expect(child).toBeInTheDocument(); - }); + describe('when url property is not set', () => { + it('then it renders the menu trigger as a button', () => { + getTestContext(); + + expect(screen.getAllByRole('button')).toHaveLength(1); + }); + + describe('and clicking on the menu trigger button', () => { + it('then the onClick handler should be called', () => { + getTestContext(); + + userEvent.click(screen.getByRole('button')); + + expect(onClickMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('and hovering over the menu trigger button', () => { + it('then the menu items should be visible', () => { + getTestContext(); + + userEvent.hover(screen.getByRole('button')); + + expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and tabbing to the menu trigger button', () => { + it('then the menu items should be visible', () => { + getTestContext(); + + userEvent.tab(); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and pressing arrow right on the menu trigger button', () => { + it('then the correct menu item should receive focus', () => { + getTestContext(); - it('wraps the children in a link to the url if provided', () => { - const mockLabel = 'Hello'; - const mockUrl = '/route'; - render( - - -
- - - ); - - const child = screen.getByTestId('mockChild'); - expect(child).toBeInTheDocument(); - userEvent.click(child); - expect(window.location.pathname).toEqual(mockUrl); + userEvent.tab(); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + + userEvent.keyboard('{arrowright}'); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + }); + }); }); - it('wraps the children in an onClick if provided', () => { - const mockLabel = 'Hello'; - const mockOnClick = jest.fn(); - render( - - -
- - - ); - - const child = screen.getByTestId('mockChild'); - expect(child).toBeInTheDocument(); - userEvent.click(child); - expect(mockOnClick).toHaveBeenCalled(); + describe('when url property is set', () => { + it('then it renders the menu trigger as a link', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + + expect(screen.getAllByRole('link')).toHaveLength(1); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.grafana.com'); + }); + + describe('and hovering over the menu trigger link', () => { + it('then the menu items should be visible', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + + userEvent.hover(screen.getByRole('link')); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and tabbing to the menu trigger link', () => { + it('then the menu items should be visible', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + + userEvent.tab(); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + }); + + describe('and pressing arrow right on the menu trigger link', () => { + it('then the correct menu item should receive focus', () => { + getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + + userEvent.tab(); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + + userEvent.keyboard('{arrowright}'); + expect(screen.getAllByRole('menuitem')).toHaveLength(3); + expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); + expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); + expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); + }); + }); + + describe('when appSubUrl is configured and user clicks on menuitem link', () => { + it('then location service should be called with correct url', async () => { + const { pushMock } = getTestContext( + { + link: { + ...defaults.link, + url: 'https://www.grafana.com', + children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }], + }, + }, + '/grafana' + ); + + userEvent.hover(screen.getByRole('link')); + await waitFor(() => { + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByText('New')); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledTimes(1); + expect(pushMock).toHaveBeenCalledWith('/dashboard/new'); + }); + }); + }); + + describe('when appSubUrl is not configured and user clicks on menuitem link', () => { + it('then location service should be called with correct url', async () => { + const { pushMock } = getTestContext({ + link: { + ...defaults.link, + url: 'https://www.grafana.com', + children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }], + }, + }); + + userEvent.hover(screen.getByRole('link')); + await waitFor(() => { + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByText('New')); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledTimes(1); + expect(pushMock).toHaveBeenCalledWith('/grafana/dashboard/new'); + }); + }); + }); }); }); diff --git a/public/app/core/components/NavBar/NavBarItem.tsx b/public/app/core/components/NavBar/NavBarItem.tsx index b12a34346a51c..84648fb38d2f3 100644 --- a/public/app/core/components/NavBar/NavBarItem.tsx +++ b/public/app/core/components/NavBar/NavBarItem.tsx @@ -1,139 +1,132 @@ import React, { ReactNode } from 'react'; +import { Item } from '@react-stately/collections'; import { css, cx } from '@emotion/css'; -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { Link, useTheme2 } from '@grafana/ui'; -import NavBarDropdown from './NavBarDropdown'; +import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data'; +import { IconName, useTheme2 } from '@grafana/ui'; +import { locationService } from '@grafana/runtime'; + +import { NavBarMenuItem } from './NavBarMenuItem'; +import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; +import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger'; +import { NavBarItemMenu } from './NavBarItemMenu'; +import { getNavModelItemKey } from './utils'; export interface Props { isActive?: boolean; children: ReactNode; className?: string; - label: string; - menuItems?: NavModelItem[]; - menuSubTitle?: string; - onClick?: () => void; reverseMenuDirection?: boolean; showMenu?: boolean; - target?: HTMLAnchorElement['target']; - url?: string; + link: NavModelItem; } const NavBarItem = ({ isActive = false, children, className, - label, - menuItems = [], - menuSubTitle, - onClick, reverseMenuDirection = false, showMenu = true, - target, - url, + link, }: Props) => { const theme = useTheme2(); - const styles = getStyles(theme, isActive); - let element = ( - - ); + const menuItems = link.children ?? []; + const menuItemsSorted = reverseMenuDirection ? menuItems.reverse() : menuItems; + const filteredItems = menuItemsSorted + .filter((item) => !item.hideFromMenu) + .map((i) => ({ ...i, menuItemType: NavMenuItemType.Item })); + const adjustHeightForBorder = filteredItems.length === 0; + const styles = getStyles(theme, adjustHeightForBorder, isActive); + const section: NavModelItem = { + ...link, + children: filteredItems, + menuItemType: NavMenuItemType.Section, + }; + const items: NavModelItem[] = [section].concat(filteredItems); + const onNavigate = (item: NavModelItem) => { + const { url, target, onClick } = item; + if (!url) { + onClick?.(); + return; + } + + if (!target && url.startsWith('/')) { + locationService.push(locationUtil.stripBaseFromUrl(url)); + } else { + window.open(url, target); + } + }; - if (url) { - element = - !target && url.startsWith('/') ? ( - + + - {children} - - ) : ( - - {children} - - ); - } + {(item: NavModelItem) => { + if (item.menuItemType === NavMenuItemType.Section) { + return ( + + + + ); + } - return ( -
- {element} - {showMenu && ( - - )} -
+ return ( + + + + ); + }} +
+
+ + ) : ( + + {children} + ); }; export default NavBarItem; -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ - container: css` - position: relative; - color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; - - &:hover { - background-color: ${theme.colors.action.hover}; - color: ${theme.colors.text.primary}; - - // TODO don't use a hardcoded class here, use isVisible in NavBarDropdown - .navbar-dropdown { - opacity: 1; - visibility: visible; - } - } - `, - element: css` - background-color: transparent; - border: none; - color: inherit; - display: block; - line-height: ${theme.components.sidemenu.width}px; - padding: 0; - text-align: center; - width: ${theme.components.sidemenu.width}px; - - &::before { - display: ${isActive ? 'block' : 'none'}; - content: ' '; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - border-radius: 2px; - background-image: ${theme.colors.gradients.brandVertical}; - } - - &:focus-visible { - background-color: ${theme.colors.action.hover}; - box-shadow: none; - color: ${theme.colors.text.primary}; - outline: 2px solid ${theme.colors.primary.main}; - outline-offset: -2px; - transition: none; - } - `, - icon: css` - height: 100%; +const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({ + ...getNavBarItemWithoutMenuStyles(theme, isActive), + header: css` + color: ${theme.colors.text.primary}; + height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px; + font-size: ${theme.typography.h4.fontSize}; + font-weight: ${theme.typography.h4.fontWeight}; + padding: ${theme.spacing(1)} ${theme.spacing(2)}; + white-space: nowrap; width: 100%; - - img { - border-radius: 50%; - height: ${theme.spacing(3)}; - width: ${theme.spacing(3)}; - } + `, + item: css` + color: ${theme.colors.text.primary}; `, }); diff --git a/public/app/core/components/NavBar/NavBarItemMenu.tsx b/public/app/core/components/NavBar/NavBarItemMenu.tsx new file mode 100644 index 0000000000000..000b996dc0ea5 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemMenu.tsx @@ -0,0 +1,105 @@ +import React, { ReactElement, useEffect, useRef } from 'react'; +import { css } from '@emotion/css'; +import { useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data'; +import { SpectrumMenuProps } from '@react-types/menu'; +import { useMenu } from '@react-aria/menu'; +import { useTreeState } from '@react-stately/tree'; +import { mergeProps } from '@react-aria/utils'; + +import { getNavModelItemKey } from './utils'; +import { useNavBarItemMenuContext } from './context'; +import { NavBarItemMenuItem } from './NavBarItemMenuItem'; + +export interface NavBarItemMenuProps extends SpectrumMenuProps { + onNavigate: (item: NavModelItem) => void; + adjustHeightForBorder: boolean; + reverseMenuDirection?: boolean; +} + +export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null { + const { reverseMenuDirection, adjustHeightForBorder, disabledKeys, onNavigate, ...rest } = props; + const contextProps = useNavBarItemMenuContext(); + const completeProps = { + ...mergeProps(contextProps, rest), + }; + const { menuHasFocus, menuProps: contextMenuProps = {} } = contextProps; + const theme = useTheme2(); + const styles = getStyles(theme, reverseMenuDirection); + const state = useTreeState({ ...rest, disabledKeys }); + const ref = useRef(null); + const { menuProps } = useMenu(completeProps, { ...state }, ref); + const allItems = [...state.collection]; + const items = allItems.filter((item) => item.value.menuItemType === NavMenuItemType.Item); + const section = allItems.find((item) => item.value.menuItemType === NavMenuItemType.Section); + + useEffect(() => { + if (menuHasFocus && !state.selectionManager.isFocused) { + state.selectionManager.setFocusedKey(section?.key ?? ''); + state.selectionManager.setFocused(true); + } else if (!menuHasFocus) { + state.selectionManager.setFocused(false); + state.selectionManager.setFocusedKey(''); + state.selectionManager.clearSelection(); + } + }, [menuHasFocus, state.selectionManager, reverseMenuDirection, section?.key]); + + if (!section) { + return null; + } + + const menuSubTitle = section.value.subTitle; + + const sectionComponent = ( + + ); + + const itemComponents = items.map((item) => ( + + )); + + const subTitleComponent = menuSubTitle && ( +
  • + {menuSubTitle} +
  • + ); + + const menu = [sectionComponent, itemComponents, subTitleComponent]; + + return ( +
      + {reverseMenuDirection ? menu.reverse() : menu} +
    + ); +} + +function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) { + return { + menu: css` + background-color: ${theme.colors.background.primary}; + border: 1px solid ${theme.components.panel.borderColor}; + bottom: ${reverseDirection ? 0 : 'auto'}; + box-shadow: ${theme.shadows.z3}; + display: flex; + flex-direction: column; + left: 100%; + list-style: none; + min-width: 140px; + position: absolute; + top: ${reverseDirection ? 'auto' : 0}; + transition: ${theme.transitions.create('opacity')}; + z-index: ${theme.zIndex.sidemenu}; + list-style: none; + `, + subtitle: css` + background-color: transparent; + border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak}; + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.bodySmall.fontSize}; + font-weight: ${theme.typography.bodySmall.fontWeight}; + padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)}; + text-align: left; + white-space: nowrap; + `, + }; +} diff --git a/public/app/core/components/NavBar/NavBarItemMenuItem.tsx b/public/app/core/components/NavBar/NavBarItemMenuItem.tsx new file mode 100644 index 0000000000000..1c6a5cd5dabd9 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemMenuItem.tsx @@ -0,0 +1,102 @@ +import React, { ReactElement, useRef, useState } from 'react'; +import { css } from '@emotion/css'; +import { Icon, useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data'; +import { useMenuItem } from '@react-aria/menu'; +import { useFocus, useKeyboard } from '@react-aria/interactions'; +import { TreeState } from '@react-stately/tree'; +import { mergeProps } from '@react-aria/utils'; +import { Node } from '@react-types/shared'; + +import { useNavBarItemMenuContext } from './context'; +import { NestedSubMenu } from './NestedSubMenu'; + +export interface NavBarItemMenuItemProps { + item: Node; + state: TreeState; + onNavigate: (item: NavModelItem) => void; +} + +export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement { + const { onClose, onLeft } = useNavBarItemMenuContext(); + const { key, rendered } = item; + const ref = useRef(null); + const isDisabled = state.disabledKeys.has(key); + + // style to the focused menu item + const [isFocused, setFocused] = useState(false); + const { focusProps } = useFocus({ onFocusChange: setFocused, isDisabled }); + const theme = useTheme2(); + const isSection = item.value.menuItemType === 'section'; + const isDivider = !!item.value.divider; + const styles = getStyles(theme, isFocused, isSection, isDivider); + const onAction = () => { + onNavigate(item.value); + onClose(); + }; + + let { menuItemProps } = useMenuItem( + { + isDisabled, + 'aria-label': item['aria-label'], + key, + closeOnSelect: true, + onClose, + onAction, + }, + state, + ref + ); + + const { keyboardProps } = useKeyboard({ + onKeyDown: (e) => { + if (e.key === 'ArrowLeft') { + onLeft(); + } + e.continuePropagation(); + }, + }); + + return ( +
  • + {rendered} + {!!item.value.children?.length && item.value.menuItemType === NavMenuItemType.Item && ( + <> + {} + + + )} +
  • + ); +} + +function getStyles(theme: GrafanaTheme2, isFocused: boolean, isSection: boolean, isDivider: boolean) { + return { + menuItem: css` + position: relative; + color: ${theme.colors.text.primary}; + display: flex; + align-items: center; + + &:hover { + background-color: ${isDivider ? 'transparent' : theme.colors.action.hover}; + } + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + outline-offset: -2px; + transition: none; + } + + &:hover { + & > ul { + opacity: 1; + visibility: visible; + } + } + `, + }; +} diff --git a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx new file mode 100644 index 0000000000000..c93dcf0ff7383 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx @@ -0,0 +1,208 @@ +import React, { ReactElement, useState } from 'react'; +import { css, cx } from '@emotion/css'; +import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { MenuTriggerProps } from '@react-types/menu'; +import { useMenuTriggerState } from '@react-stately/menu'; +import { useMenuTrigger } from '@react-aria/menu'; +import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions'; +import { useButton } from '@react-aria/button'; +import { DismissButton, useOverlay } from '@react-aria/overlays'; +import { FocusScope } from '@react-aria/focus'; + +import { NavBarItemMenuContext } from './context'; + +export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { + children: ReactElement; + item: NavModelItem; + isActive?: boolean; + label: string; +} + +export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement { + const { item, isActive, label, children: menu, ...rest } = props; + const [menuHasFocus, setMenuHasFocus] = useState(false); + const theme = useTheme2(); + const styles = getStyles(theme, isActive); + + // Create state based on the incoming props + const state = useMenuTriggerState({ ...rest }); + + // Get props for the menu trigger and menu elements + const ref = React.useRef(null); + const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref); + + const { hoverProps } = useHover({ + onHoverChange: (isHovering) => { + if (isHovering) { + state.open(); + } else { + state.close(); + } + }, + }); + + const { focusWithinProps } = useFocusWithin({ + onFocusWithinChange: (isFocused) => { + if (isFocused) { + state.open(); + } + if (!isFocused) { + state.close(); + setMenuHasFocus(false); + } + }, + }); + + const { keyboardProps } = useKeyboard({ + onKeyDown: (e) => { + switch (e.key) { + case 'ArrowRight': + if (!state.isOpen) { + state.open(); + } + setMenuHasFocus(true); + break; + default: + break; + } + }, + }); + + // Get props for the button based on the trigger props from useMenuTrigger + const { buttonProps } = useButton(menuTriggerProps, ref); + + let element = ( + + ); + + if (item?.url) { + element = + !item.target && item.url.startsWith('/') ? ( + + + {item?.icon && } + {item?.img && {`${item.text}} + + + ) : ( + + + {item?.icon && } + {item?.img && {`${item.text}} + + + ); + } + + const overlayRef = React.useRef(null); + const { overlayProps } = useOverlay( + { + onClose: () => state.close(), + isOpen: state.isOpen, + isDismissable: true, + }, + overlayRef + ); + + return ( +
    + {element} + {state.isOpen && ( + state.close(), + onLeft: () => { + setMenuHasFocus(false); + ref.current?.focus(); + }, + }} + > + +
    + state.close()} /> + {menu} + state.close()} /> +
    +
    +
    + )} +
    + ); +} + +const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({ + element: css` + background-color: transparent; + border: none; + color: inherit; + display: block; + line-height: ${theme.components.sidemenu.width}px; + padding: 0; + text-align: center; + width: ${theme.components.sidemenu.width}px; + + &::before { + display: ${isActive ? 'block' : 'none'}; + content: ' '; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 2px; + background-image: ${theme.colors.gradients.brandVertical}; + } + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + outline-offset: -2px; + transition: none; + } + `, + icon: css` + height: 100%; + width: 100%; + + img { + border-radius: 50%; + height: ${theme.spacing(3)}; + width: ${theme.spacing(3)}; + } + `, +}); diff --git a/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx new file mode 100644 index 0000000000000..0076852adb120 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx @@ -0,0 +1,118 @@ +import { GrafanaTheme2 } from '../../../../../packages/grafana-data'; +import { css, cx } from '@emotion/css'; +import React, { ReactNode } from 'react'; +import { Link, useTheme2 } from '../../../../../packages/grafana-ui'; + +export interface NavBarItemWithoutMenuProps { + label: string; + children: ReactNode; + className?: string; + url?: string; + target?: string; + isActive?: boolean; + onClick?: () => void; +} + +export function NavBarItemWithoutMenu({ + label, + children, + className, + url, + target, + isActive = false, + onClick, +}: NavBarItemWithoutMenuProps) { + const theme = useTheme2(); + const styles = getNavBarItemWithoutMenuStyles(theme, isActive); + + return ( +
  • + {!url && ( + + )} + {url && ( + <> + {!target && url.startsWith('/') ? ( + + {children} + + ) : ( + + {children} + + )} + + )} +
  • + ); +} + +export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) { + return { + container: css` + position: relative; + color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; + + &:hover { + background-color: ${theme.colors.action.hover}; + color: ${theme.colors.text.primary}; + + // TODO don't use a hardcoded class here, use isVisible in NavBarDropdown + .navbar-dropdown { + opacity: 1; + visibility: visible; + } + } + `, + element: css` + background-color: transparent; + border: none; + color: inherit; + display: block; + line-height: ${theme.components.sidemenu.width}px; + padding: 0; + text-align: center; + width: ${theme.components.sidemenu.width}px; + + &::before { + display: ${isActive ? 'block' : 'none'}; + content: ' '; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 2px; + background-image: ${theme.colors.gradients.brandVertical}; + } + + &:focus-visible { + background-color: ${theme.colors.action.hover}; + box-shadow: none; + color: ${theme.colors.text.primary}; + outline: 2px solid ${theme.colors.primary.main}; + outline-offset: 2px; + transition: none; + } + `, + icon: css` + height: 100%; + width: 100%; + + img { + border-radius: 50%; + height: ${theme.spacing(3)}; + width: ${theme.spacing(3)}; + } + `, + }; +} diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index a8124e6d6afc1..198c68173b020 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -64,6 +64,8 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) { target={childLink.target} text={childLink.text} url={childLink.url} + isMobile={true} + childrenItems={childLink.children} /> ) )} diff --git a/public/app/core/components/NavBar/NavBarMenuItem.tsx b/public/app/core/components/NavBar/NavBarMenuItem.tsx index 79c9997a6df43..6a707f0da14e5 100644 --- a/public/app/core/components/NavBar/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/NavBarMenuItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; import { css } from '@emotion/css'; @@ -12,9 +12,23 @@ export interface Props { target?: HTMLAnchorElement['target']; text: string; url?: string; + adjustHeightForBorder?: boolean; + isMobile?: boolean; + childrenItems?: NavModelItem[]; } -export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverrides, target, text, url }: Props) { +export function NavBarMenuItem({ + icon, + isActive, + isDivider, + onClick, + styleOverrides, + target, + text, + url, + isMobile = false, + childrenItems = [], +}: Props) { const theme = useTheme2(); const styles = getStyles(theme, isActive, styleOverrides); @@ -48,6 +62,31 @@ export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverri ); } + if (isMobile) { + return ( + <> + {isDivider ? ( +
  • + ) : ( +
  • {element}
  • + )} + {!!childrenItems.length && + childrenItems.map((item) => ( + + ))} + + ); + } return isDivider ?
  • :
  • {element}
  • ; } @@ -60,6 +99,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr height: 1px; margin: ${theme.spacing(1)} 0; overflow: hidden; + flex: 1; `, element: css` align-items: center; @@ -75,12 +115,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr white-space: nowrap; width: 100%; - &:hover, - &:focus-visible { - background-color: ${theme.colors.action.hover}; - color: ${theme.colors.text.primary}; - } - &:focus-visible { box-shadow: none; outline: 2px solid ${theme.colors.primary.main}; diff --git a/public/app/core/components/NavBar/NavBarNext.tsx b/public/app/core/components/NavBar/NavBarNext.tsx index 73bb74788f466..41d9f8fed02e8 100644 --- a/public/app/core/components/NavBar/NavBarNext.tsx +++ b/public/app/core/components/NavBar/NavBarNext.tsx @@ -5,14 +5,15 @@ import { cloneDeep } from 'lodash'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { locationService } from '@grafana/runtime'; -import { Branding } from 'app/core/components/Branding/Branding'; import config from 'app/core/config'; import { KioskMode } from 'app/types'; import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils'; import { OrgSwitcher } from '../OrgSwitcher'; import { NavBarSection } from './NavBarSection'; -import NavBarItem from './NavBarItem'; import { NavBarMenu } from './NavBarMenu'; +import NavBarItem from './NavBarItem'; +import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; +import { Branding } from '../Branding/Branding'; const onOpenSearch = () => { locationService.partial({ search: 'open' }); @@ -22,6 +23,7 @@ const searchItem: NavModelItem = { id: SEARCH_ITEM_ID, onClick: onOpenSearch, text: 'Search dashboards', + icon: 'search', }; export const NavBarNext: FC = React.memo(() => { @@ -56,20 +58,10 @@ export const NavBarNext: FC = React.memo(() => {
    - setMenuOpen(!menuOpen)} - label="Main menu" - className={styles.grafanaLogo} - showMenu={false} - > + setMenuOpen(!menuOpen)}> - - + + @@ -79,10 +71,7 @@ export const NavBarNext: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} @@ -93,16 +82,7 @@ export const NavBarNext: FC = React.memo(() => { {pluginItems.length > 0 && ( {pluginItems.map((link, index) => ( - + {link.icon && } {link.img && {`${link.text}} @@ -117,13 +97,8 @@ export const NavBarNext: FC = React.memo(() => { {link.icon && } {link.img && {`${link.text}} diff --git a/public/app/core/components/NavBar/NavBarSection.tsx b/public/app/core/components/NavBar/NavBarSection.tsx index 9aa93207a73c7..4d4d694bf44e3 100644 --- a/public/app/core/components/NavBar/NavBarSection.tsx +++ b/public/app/core/components/NavBar/NavBarSection.tsx @@ -2,7 +2,7 @@ import React, { ReactNode } from 'react'; import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; -import config from '../../config'; +import { config } from '@grafana/runtime'; export interface Props { children: ReactNode; @@ -10,7 +10,7 @@ export interface Props { } export function NavBarSection({ children, className }: Props) { - const newNavigationEnabled = config.featureToggles.newNavigation; + const newNavigationEnabled = Boolean(config.featureToggles.newNavigation); const theme = useTheme2(); const styles = getStyles(theme, newNavigationEnabled); @@ -24,6 +24,7 @@ export function NavBarSection({ children, className }: Props) { const getStyles = (theme: GrafanaTheme2, newNavigationEnabled: boolean) => ({ container: css` display: none; + list-style: none; ${theme.breakpoints.up('md')} { background-color: ${newNavigationEnabled ? theme.colors.background.primary : 'inherit'}; diff --git a/public/app/core/components/NavBar/NestedSubMenu.tsx b/public/app/core/components/NavBar/NestedSubMenu.tsx new file mode 100644 index 0000000000000..84f06754ceb15 --- /dev/null +++ b/public/app/core/components/NavBar/NestedSubMenu.tsx @@ -0,0 +1,72 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { Icon, IconName, Link, useStyles2 } from '@grafana/ui'; +import React, { FC } from 'react'; + +interface Props { + items?: NavModelItem[]; +} + +export const NestedSubMenu: FC = ({ items = [] }) => { + const styles = useStyles2(getStyles); + return ( +
      + {items.map((item) => ( +
    • + + {item.icon && } + {item.text} + + {!!item.children?.length && !!item.children.find((child) => !child.hideFromMenu) && ( + <> + {} + + + )} +
    • + ))} +
    + ); +}; + +const getStyles = ({ colors, components, spacing, zIndex }: GrafanaTheme2) => { + return { + menu: css` + background-color: ${colors.background.primary}; + border: 1px solid ${components.panel.borderColor}; + position: absolute; + top: 0; + left: 100%; + min-width: 140px; + list-style: none; + opacity: 0; + visibility: hidden; + z-index: ${zIndex.sidemenu}; + + & > li { + position: relative; + cursor: pointer; + display: flex; + align-items: center; + text-align: left; + + & > a { + width: 100%; + padding: 5px 12px 5px 10px; + white-space: nowrap; + } + + &:hover { + background-color: ${colors.action.hover}; + & > ul { + opacity: 1; + visibility: visible; + } + } + } + `, + icon: css` + margin-right: ${spacing(1)}; + `, + }; +}; diff --git a/public/app/core/components/NavBar/constants.ts b/public/app/core/components/NavBar/constants.ts new file mode 100644 index 0000000000000..705aaf675d84b --- /dev/null +++ b/public/app/core/components/NavBar/constants.ts @@ -0,0 +1,255 @@ +import { NavModelItem } from '@grafana/data'; +import config from 'app/core/config'; + +export const PMM_STT_PAGE: NavModelItem = { + id: 'database-checks', + icon: 'percona-database-checks', + text: 'Advisor Checks', + subTitle: 'Percona Advisor Checks', + url: `${config.appSubUrl}/pmm-database-checks`, + breadcrumbs: [ + { + title: 'Advisor Checks', + url: `${config.appSubUrl}/pmm-database-checks`, + }, + ], + children: [ + { + id: 'failed-checks', + text: 'Failed Checks', + url: `${config.appSubUrl}/pmm-database-checks/failed-checks`, + hideFromMenu: true, + }, + { + id: 'all-checks', + text: 'All Checks', + url: `${config.appSubUrl}/pmm-database-checks/all-checks`, + hideFromMenu: true, + }, + ], +}; + +export const PMM_DBAAS_PAGE: NavModelItem = { + id: 'dbaas', + text: 'DBaaS', + subTitle: 'Percona DBaaS', + icon: 'database', + url: `${config.appSubUrl}/dbaas`, + breadcrumbs: [ + { + title: 'DBaaS', + url: `${config.appSubUrl}/dbaas`, + }, + ], + children: [ + { + id: 'kubernetes', + text: 'Kubernetes Cluster', + url: `${config.appSubUrl}/dbaas/kubernetes`, + hideFromMenu: true, + }, + { + id: 'dbclusters', + text: 'DB Cluster', + url: `${config.appSubUrl}/dbaas/dbclusters`, + hideFromMenu: true, + }, + ], +}; + +export const PMM_BACKUP_PAGE: NavModelItem = { + id: 'backup', + icon: 'history', + text: 'Backup', + subTitle: 'Percona Backups', + url: `${config.appSubUrl}/backup`, + breadcrumbs: [ + { + title: 'Backup', + url: `${config.appSubUrl}/backup`, + }, + ], + children: [ + { + id: 'backup-inventory', + text: 'Backup Inventory', + url: `${config.appSubUrl}/backup/inventory`, + hideFromMenu: true, + }, + { + id: 'restore-history', + text: 'Restore History', + url: `${config.appSubUrl}/backup/restore`, + hideFromMenu: true, + }, + { + id: 'scheduled-backups', + text: 'Scheduled Backups', + url: `${config.appSubUrl}/backup/scheduled`, + hideFromMenu: true, + }, + { + id: 'storage-locations', + text: 'Storage Locations', + url: `${config.appSubUrl}/backup/locations`, + hideFromMenu: true, + }, + ], +}; + +export const PMM_ALERTING_PAGE: NavModelItem = { + id: 'integrated-alerting', + icon: 'bell', + text: 'Integrated Alerting', + url: `${config.appSubUrl}/integrated-alerting`, + subTitle: 'Percona Integrated Alerting', + breadcrumbs: [ + { + title: 'Integrated Alerting', + url: `${config.appSubUrl}/integrated-alerting`, + }, + ], + children: [ + { + id: 'integrated-alerting-alerts', + text: 'Alerts', + url: `${config.appSubUrl}/integrated-alerting/alerts`, + }, + { + id: 'integrated-alerting-rules', + text: 'Alert Rules', + url: `${config.appSubUrl}/integrated-alerting/alert-rules`, + }, + { + id: 'integrated-alerting-templates', + text: 'Alert Rule Templates', + url: `${config.appSubUrl}/integrated-alerting/alert-rule-templates`, + }, + { + id: 'integrated-alerting-notification-channels', + text: 'Notification Channels', + url: `${config.appSubUrl}/integrated-alerting/notification-channels`, + }, + ], +}; + +export const PMM_INVENTORY_PAGE: NavModelItem = { + id: 'inventory', + icon: 'percona-inventory', + text: 'PMM Inventory', + url: `${config.appSubUrl}/inventory`, + subTitle: 'Percona PMM Inventory', + breadcrumbs: [ + { + title: 'PMM Inventory', + url: `${config.appSubUrl}/inventory`, + }, + ], + children: [ + { + id: 'inventory-services', + text: 'Services', + url: `${config.appSubUrl}/inventory/services`, + hideFromMenu: true, + }, + { + id: 'inventory-agents', + text: 'Agents', + url: `${config.appSubUrl}/inventory/agents`, + hideFromMenu: true, + }, + { + id: 'inventory-nodes', + text: 'Nodes', + url: `${config.appSubUrl}/inventory/nodes`, + hideFromMenu: true, + }, + ], +}; + +export const PMM_ADD_INSTANCE_PAGE: NavModelItem = { + id: 'add-instance', + url: `${config.appSubUrl}/add-instance`, + icon: 'percona-add', + subTitle: 'Add Instance to PMM', + text: 'Add Instance', + hideFromTabs: true, +}; + +export const getPmmSettingsPage = (alertingEnabled = false): NavModelItem => { + const children: NavModelItem[] = [ + { + id: 'settings-metrics-resolution', + text: 'Metrics Resolution', + url: `${config.appSubUrl}/settings/metrics-resolution`, + }, + { + id: 'settings-advanced', + text: 'Advanced Settings', + url: `${config.appSubUrl}/settings/advanced-settings`, + }, + { + id: 'settings-ssh', + text: 'SSH Key', + url: `${config.appSubUrl}/settings/ssh-key`, + }, + { + id: 'settings-alert-manager', + text: 'Alertmanager Integration', + url: `${config.appSubUrl}/settings/am-integration`, + }, + { + id: 'settings-percona-platform', + text: 'Percona Platform', + url: `${config.appSubUrl}/settings/percona-platform`, + }, + ]; + + if (alertingEnabled) { + children.push({ + id: 'settings-communication', + text: 'Communication', + url: `${config.appSubUrl}/settings/communication`, + }); + } + const page: NavModelItem = { + id: 'settings', + icon: 'percona-setting', + text: 'Settings', + url: `${config.appSubUrl}/settings`, + subTitle: 'Percona Settings', + breadcrumbs: [ + { + title: 'Settings', + url: `${config.appSubUrl}/settings`, + }, + ], + children, + }; + + return page; +}; + +export const PMM_TICKETS_PAGE: NavModelItem = { + id: 'tickets', + icon: 'ticket', + text: 'List of tickets opened by Customer Organization', + subTitle: 'Percona Support Tickets from Portal', + url: `${config.appSubUrl}/tickets`, +}; + +export const PMM_ENTITLEMENTS_PAGE: NavModelItem = { + id: 'entitlements', + icon: 'cloud', + text: 'Entitlements', + subTitle: 'Percona Entitlements', + url: `${config.appSubUrl}/entitlements`, +}; + +export const PMM_ENVIRONMENT_OVERVIEW_PAGE: NavModelItem = { + id: 'environment-overview', + icon: 'clouds', + text: 'Environment Overview', + subTitle: 'Percona Environment Overview', + url: `${config.appSubUrl}/environment-overview`, +}; diff --git a/public/app/core/components/NavBar/context.tsx b/public/app/core/components/NavBar/context.tsx new file mode 100644 index 0000000000000..e2c5b0c075953 --- /dev/null +++ b/public/app/core/components/NavBar/context.tsx @@ -0,0 +1,18 @@ +import { createContext, HTMLAttributes, useContext } from 'react'; + +export interface NavBarItemMenuContextProps { + menuHasFocus: boolean; + onClose: () => void; + onLeft: () => void; + menuProps?: HTMLAttributes; +} + +export const NavBarItemMenuContext = createContext({ + menuHasFocus: false, + onClose: () => undefined, + onLeft: () => undefined, +}); + +export function useNavBarItemMenuContext(): NavBarItemMenuContextProps { + return useContext(NavBarItemMenuContext); +} diff --git a/public/app/core/components/NavBar/utils.ts b/public/app/core/components/NavBar/utils.ts index 3b122872ffc2c..41be01eba576a 100644 --- a/public/app/core/components/NavBar/utils.ts +++ b/public/app/core/components/NavBar/utils.ts @@ -1,19 +1,27 @@ import { Location } from 'history'; import { NavModelItem, NavSection } from '@grafana/data'; -import { getConfig } from 'app/core/config'; +import { config } from 'app/core/config'; import { contextSrv } from 'app/core/services/context_srv'; import { ShowModalReactEvent } from '../../../types/events'; import appEvents from '../../app_events'; import { getFooterLinks } from '../Footer/Footer'; import { HelpModal } from '../help/HelpModal'; +import { PMM_ADD_INSTANCE_PAGE } from './constants'; export const SEARCH_ITEM_ID = 'search'; +const DIVIDER = { + id: 'divider', + text: 'Divider', + divider: true, + hideFromTabs: true, +}; + export const getForcedLoginUrl = (url: string) => { const queryParams = new URLSearchParams(url.split('?')[1]); queryParams.append('forceLogin', 'true'); - return `${getConfig().appSubUrl}${url.split('?')[0]}?${queryParams.toString()}`; + return `${config.appSubUrl}${url.split('?')[0]}?${queryParams.toString()}`; }; export const enrichConfigItems = ( @@ -94,7 +102,7 @@ export const getActiveItem = ( pathname: string, currentBestMatch?: NavModelItem ): NavModelItem | undefined => { - const newNavigationEnabled = getConfig().featureToggles.newNavigation; + const newNavigationEnabled = config.featureToggles.newNavigation; const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/'; for (const link of navTree) { @@ -136,3 +144,98 @@ export const isSearchActive = (location: Location) => { const query = new URLSearchParams(location.search); return query.get('search') === 'open'; }; + +export function getNavModelItemKey(item: NavModelItem) { + return item.id ?? item.text; +} + +export const buildIntegratedAlertingMenuItem = (mainLinks: NavModelItem[]): NavModelItem[] => { + const integratedAlertingLink = { + id: 'integrated-alerting', + text: 'Integrated Alerting', + icon: 'bell', + url: `${config.appSubUrl}/integrated-alerting`, + }; + + const alertingIndex = mainLinks.findIndex(({ id }) => id === 'alerting'); + + if (alertingIndex === -1) { + mainLinks.push({ + id: 'alerting', + text: 'Alerting', + icon: 'bell', + url: `${config.appSubUrl}/integrated-alerting/alerts`, + subTitle: 'Alert rules & notifications', + children: [integratedAlertingLink], + }); + } else { + mainLinks[alertingIndex].children?.unshift(integratedAlertingLink, DIVIDER); + } + + return mainLinks; +}; + +export const buildInventoryAndSettings = (mainLinks: NavModelItem[]): NavModelItem[] => { + const inventoryLink: NavModelItem = { + id: 'inventory', + icon: 'percona-inventory', + text: 'PMM Inventory', + url: `${config.appSubUrl}/inventory`, + children: [ + { + id: 'inventory-list', + url: `${config.appSubUrl}/inventory`, + icon: 'percona-inventory', + text: 'Inventory List', + hideFromTabs: true, + children: [ + { + id: 'inventory-services', + text: 'Services', + url: `${config.appSubUrl}/inventory/services`, + hideFromMenu: true, + }, + { + id: 'inventory-agents', + text: 'Agents', + url: `${config.appSubUrl}/inventory/agents`, + hideFromMenu: true, + }, + { + id: 'inventory-nodes', + text: 'Nodes', + url: `${config.appSubUrl}/inventory/nodes`, + hideFromMenu: true, + }, + ], + }, + PMM_ADD_INSTANCE_PAGE, + ], + }; + const settingsLink = { + id: 'settings', + icon: 'percona-setting', + text: 'Settings', + url: `${config.appSubUrl}/settings`, + }; + const configNode = mainLinks.find((link) => link.id === 'cfg'); + + if (!configNode) { + mainLinks.push({ + id: 'cfg', + text: 'Configuration', + icon: 'cog', + url: `${config.appSubUrl}/inventory`, + subTitle: 'Configuration', + children: [inventoryLink, DIVIDER, settingsLink], + }); + } else { + if (!configNode.children) { + configNode.children = []; + } + configNode.url = `${config.appSubUrl}/inventory`; + configNode.children.unshift(inventoryLink, DIVIDER, settingsLink); + } + + return mainLinks; +}; diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 40034d4299f61..b9bd2e80fd244 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -14,6 +14,8 @@ import { css, cx } from '@emotion/css'; interface Props extends HTMLAttributes { children: React.ReactNode; navModel?: NavModel; + vertical?: boolean; + tabsDataTestId?: string; } export interface PageType extends FC { @@ -21,7 +23,14 @@ export interface PageType extends FC { Contents: typeof PageContents; } -export const Page: PageType = ({ navModel, children, className, ...otherProps }) => { +export const Page: PageType = ({ + navModel, + vertical = false, + tabsDataTestId = '', + children, + className, + ...otherProps +}) => { const styles = useStyles2(getStyles); useEffect(() => { @@ -36,8 +45,8 @@ export const Page: PageType = ({ navModel, children, className, ...otherProps }) return (
    -
    - {navModel && } +
    + {navModel && } {children}
    diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx index edfc9a019a653..28e2a01334b20 100644 --- a/public/app/core/components/Page/PageContents.tsx +++ b/public/app/core/components/Page/PageContents.tsx @@ -9,8 +9,13 @@ interface Props { isLoading?: boolean; children: React.ReactNode; className?: string; + dataTestId?: string; } -export const PageContents: FC = ({ isLoading, children, className }) => { - return
    {isLoading ? : children}
    ; +export const PageContents: FC = ({ isLoading, children, className, dataTestId }) => { + return ( +
    + {isLoading ? : children} +
    + ); }; diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index 9b6c8ff0194f0..0d3c8a08336ed 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,11 +1,13 @@ import React, { FC } from 'react'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { Tab, TabsBar, Icon, IconName, useStyles2 } from '@grafana/ui'; import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme2 } from '@grafana/data'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; export interface Props { model: NavModel; + vertical?: boolean; + tabsDataTestId?: string; } const SelectNav = ({ children, customCss }: { children: NavModelItem[]; customCss: string }) => { @@ -44,15 +46,26 @@ const SelectNav = ({ children, customCss }: { children: NavModelItem[]; customCs ); }; -const Navigation = ({ children }: { children: NavModelItem[] }) => { +const Navigation = ({ + children, + vertical = false, + dataTestId = '', +}: { + children: NavModelItem[]; + vertical?: boolean; + dataTestId?: string; + tabsdataTestId?: string; +}) => { + const styles = useStyles2(getStyles); + if (!children || children.length === 0) { return null; } return ( -
    @@ -124,23 +141,43 @@ function renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) { for (const bc of breadcrumbs) { if (bc.url) { breadcrumbsResult.push( - + {bc.title} ); } else { - breadcrumbsResult.push( / {bc.title}); + breadcrumbsResult.push( + + {' '} + / {bc.title} + + ); } } - breadcrumbsResult.push( / {title}); + breadcrumbsResult.push( + + {' '} + / {title} + + ); return

    {breadcrumbsResult}

    ; } -const getStyles = (theme: GrafanaTheme2) => ({ - headerCanvas: css` - background: ${theme.colors.background.canvas}; - `, -}); +const getStyles = (theme: GrafanaTheme2) => { + const maxWidthBreakpoint = + theme.breakpoints.values.xxl + theme.spacing.gridSize * 2 + theme.components.sidemenu.width; + return { + headerCanvas: css` + background: ${theme.colors.background.canvas}; + `, + verticalNav: css` + width: 20%; + @media (min-width: ${maxWidthBreakpoint}px) { + width: 12%; + } + `, + }; +}; export default PageHeader; diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx index 84f6fcd322ffb..c5d2e188c34e9 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react'; -import { Icon, renderOrCallToRender, stylesFactory, useTheme } from '@grafana/ui'; +import { Icon, ReactUtils, stylesFactory, useTheme } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { useUpdateEffect } from 'react-use'; @@ -69,9 +69,9 @@ export const QueryOperationRow: React.FC = ({ }, }; - const titleElement = title && renderOrCallToRender(title, renderPropArgs); - const actionsElement = actions && renderOrCallToRender(actions, renderPropArgs); - const headerElementRendered = headerElement && renderOrCallToRender(headerElement, renderPropArgs); + const titleElement = title && ReactUtils.renderOrCallToRender(title, renderPropArgs); + const actionsElement = actions && ReactUtils.renderOrCallToRender(actions, renderPropArgs); + const headerElementRendered = headerElement && ReactUtils.renderOrCallToRender(headerElement, renderPropArgs); const rowHeader = (
    @@ -180,8 +180,7 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => { text-overflow: ellipsis; `, content: css` - margin-top: ${theme.spacing.inlineFormMargin}; - margin-left: ${theme.spacing.lg}; + margin: ${theme.spacing.md}; `, disabled: css` color: ${theme.colors.textWeak}; diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx index 10d2ec9b27692..c50288fc7203a 100644 --- a/public/app/core/components/RolePicker/RolePicker.tsx +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -12,6 +12,7 @@ export interface Props { onRolesChange: (newRoles: string[]) => void; onBuiltinRoleChange: (newRole: OrgRole) => void; disabled?: boolean; + builtinRolesDisabled?: boolean; } export const RolePicker = ({ @@ -22,6 +23,7 @@ export const RolePicker = ({ onRolesChange, onBuiltinRoleChange, disabled, + builtinRolesDisabled, }: Props): JSX.Element | null => { const [isOpen, setOpen] = useState(false); const [roleOptions, setRoleOptions] = useState([]); @@ -133,6 +135,7 @@ export const RolePicker = ({ onSelect={onSelect} onUpdate={onUpdate} showGroups={query.length === 0 || query.trim() === ''} + builtinRolesDisabled={builtinRolesDisabled} /> )} diff --git a/public/app/core/components/RolePicker/RolePickerInput.tsx b/public/app/core/components/RolePicker/RolePickerInput.tsx index 1fa19ca2d05fa..3e0b172e3bdb1 100644 --- a/public/app/core/components/RolePicker/RolePickerInput.tsx +++ b/public/app/core/components/RolePicker/RolePickerInput.tsx @@ -111,7 +111,7 @@ const getRolePickerInputStyles = ( `, disabled && styles.inputDisabled, css` - min-width: 260px; + min-width: 520px; min-height: 32px; height: auto; flex-direction: row; diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx index 2742b13488cb6..ce573bc869b78 100644 --- a/public/app/core/components/RolePicker/RolePickerMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -35,6 +35,7 @@ interface RolePickerMenuProps { options: Role[]; appliedRoles: Role[]; showGroups?: boolean; + builtinRolesDisabled?: boolean; onSelect: (roles: Role[]) => void; onBuiltInRoleSelect?: (role: OrgRole) => void; onUpdate: (newBuiltInRole: OrgRole, newRoles: string[]) => void; @@ -47,6 +48,7 @@ export const RolePickerMenu = ({ options, appliedRoles, showGroups, + builtinRolesDisabled, onSelect, onBuiltInRoleSelect, onUpdate, @@ -181,6 +183,7 @@ export const RolePickerMenu = ({ value={selectedBuiltInRole} onChange={onSelectedBuiltinRoleChange} fullWidth={true} + disabled={builtinRolesDisabled} />
    {!!fixedRoles.length && @@ -194,6 +197,7 @@ export const RolePickerMenu = ({ key={i} isSelected={groupSelected(option.value) || groupPartiallySelected(option.value)} partiallySelected={groupPartiallySelected(option.value)} + disabled={option.options?.every(isNotDelegatable)} onChange={onGroupChange} onOpenSubMenu={onOpenSubMenu} onCloseSubMenu={onCloseSubMenu} @@ -221,6 +225,7 @@ export const RolePickerMenu = ({ data={option} key={i} isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))} + disabled={isNotDelegatable(option)} onChange={onChange} hideDescription /> @@ -237,6 +242,7 @@ export const RolePickerMenu = ({ data={option} key={i} isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))} + disabled={isNotDelegatable(option)} onChange={onChange} hideDescription /> @@ -329,7 +335,9 @@ export const RolePickerSubMenu = ({ disabledOptions?.find((opt) => opt.uid === option.uid)) ) } - disabled={!!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid))} + disabled={ + !!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid)) || isNotDelegatable(option) + } onChange={onSelect} hideDescription /> @@ -498,8 +506,7 @@ export const RoleMenuGroupOption = React.forwardRef { - const parts = role.name.split(':'); - return parts.length > 1 ? parts[1] : ''; + return role.group ?? 'Other'; }; const capitalize = (s: string): string => { @@ -508,6 +515,10 @@ const capitalize = (s: string): string => { const sortRolesByName = (a: Role, b: Role) => a.name.localeCompare(b.name); +const isNotDelegatable = (role: Role) => { + return role.delegatable !== undefined && !role.delegatable; +}; + export const getStyles = (theme: GrafanaTheme2) => { return { menuWrapper: css` @@ -565,7 +576,7 @@ export const getStyles = (theme: GrafanaTheme2) => { `, menuOptionBody: css` font-weight: ${theme.typography.fontWeightRegular}; - padding: ${theme.spacing(0, 1, 0, 0)}; + padding: ${theme.spacing(0, 1.5, 0, 0)}; `, menuOptionDisabled: css` color: ${theme.colors.text.disabled}; diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx index ffb152e414b25..57400f845df33 100644 --- a/public/app/core/components/RolePicker/UserRolePicker.tsx +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -11,6 +11,7 @@ export interface Props { getRoleOptions?: () => Promise; getBuiltinRoles?: () => Promise<{ [key: string]: Role[] }>; disabled?: boolean; + builtinRolesDisabled?: boolean; } export const UserRolePicker: FC = ({ @@ -21,6 +22,7 @@ export const UserRolePicker: FC = ({ getRoleOptions, getBuiltinRoles, disabled, + builtinRolesDisabled, }) => { return ( = ({ getRoles={() => fetchUserRoles(userId, orgId)} getBuiltinRoles={() => (getBuiltinRoles ? getBuiltinRoles() : fetchBuiltinRoles(orgId))} disabled={disabled} + builtinRolesDisabled={builtinRolesDisabled} /> ); }; export const fetchRoleOptions = async (orgId?: number, query?: string): Promise => { - let rolesUrl = '/api/access-control/roles'; + let rolesUrl = '/api/access-control/roles?delegatable=true'; if (orgId) { - rolesUrl += `?targetOrgId=${orgId}`; + rolesUrl += `&targetOrgId=${orgId}`; } const roles = await getBackendSrv().get(rolesUrl); if (!roles || !roles.length) { @@ -60,11 +63,16 @@ export const fetchUserRoles = async (userId: number, orgId?: number): Promise { diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 528e6cbc24d40..ecedadb50ca9d 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -24,6 +24,7 @@ import { PreferencesService } from 'app/core/services/PreferencesService'; export interface Props { resourceUri: string; + disabled?: boolean; } export interface State { @@ -126,13 +127,14 @@ export class SharedPreferences extends PureComponent { render() { const { theme, timezone, weekStart, homeDashboardId, dashboards } = this.state; + const { disabled } = this.props; const styles = getStyles(); return (
    {() => { return ( -
    +
    {c.destinationType === FieldType.time && ( - + )} diff --git a/public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx b/public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx new file mode 100644 index 0000000000000..d7a868b7c2ad8 --- /dev/null +++ b/public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { + DataTransformerID, + FieldNamePickerConfigSettings, + PluginState, + SelectableValue, + StandardEditorsRegistryItem, + TransformerRegistryItem, + TransformerUIProps, +} from '@grafana/data'; + +import { InlineField, InlineFieldRow, InlineSwitch, Select } from '@grafana/ui'; +import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; +import { ExtractFieldsOptions, extractFieldsTransformer } from './extractFields'; +import { FieldExtractorID, fieldExtractors } from './fieldExtractors'; + +const fieldNamePickerSettings: StandardEditorsRegistryItem = { + settings: { + width: 30, + placeholderText: 'Select field', + }, + name: '', + id: '', + editor: () => null, +}; + +export const extractFieldsTransformerEditor: React.FC> = ({ + input, + options, + onChange, +}) => { + const onPickSourceField = (source?: string) => { + onChange({ + ...options, + source, + }); + }; + + const onFormatChange = (format?: SelectableValue) => { + onChange({ + ...options, + format: format?.value, + }); + }; + + const onToggleReplace = () => { + onChange({ + ...options, + replace: !options.replace, + }); + }; + + const format = fieldExtractors.selectOptions(options.format ? [options.format] : undefined); + + return ( +
    + + + + + + + + - + )} @@ -80,9 +94,11 @@ export const AdminEditOrgPage: FC = ({ match }) => { `} > Organization users - {!!users.length && ( + {!canReadUsers && renderMissingUserListRightsMessage()} + {canReadUsers && !!users.length && ( { updateOrgUserRole({ ...orgUser, role }, orgId); setUsers( diff --git a/public/app/features/admin/AdminListOrgsPage.tsx b/public/app/features/admin/AdminListOrgsPage.tsx index b46de682e7111..925b62b5fd753 100644 --- a/public/app/features/admin/AdminListOrgsPage.tsx +++ b/public/app/features/admin/AdminListOrgsPage.tsx @@ -7,6 +7,8 @@ import { LinkButton } from '@grafana/ui'; import { getBackendSrv } from '@grafana/runtime'; import { AdminOrgsTable } from './AdminOrgsTable'; import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; const deleteOrg = async (orgId: number) => { return await getBackendSrv().delete('/api/orgs/' + orgId); @@ -16,10 +18,15 @@ const getOrgs = async () => { return await getBackendSrv().get('/api/orgs'); }; +const getErrorMessage = (error: any) => { + return error?.data?.message || 'An unexpected error happened.'; +}; + export const AdminListOrgsPages: FC = () => { const navIndex = useSelector((state: StoreState) => state.navIndex); const navModel = getNavModel(navIndex, 'global-orgs'); const [state, fetchOrgs] = useAsyncFn(async () => await getOrgs(), []); + const canCreateOrg = contextSrv.hasPermission(AccessControlAction.OrgsCreate); useEffect(() => { fetchOrgs(); @@ -31,12 +38,12 @@ export const AdminListOrgsPages: FC = () => { <>
    - + New org
    + {state.error && getErrorMessage(state.error)} {state.loading && 'Fetching organizations'} - {state.error} {state.value && ( = ({ orgs, onDelete }) => { + const canDeleteOrgs = contextSrv.hasPermission(AccessControlAction.OrgsDelete); + const [deleteOrg, setDeleteOrg] = useState(); return ( @@ -34,6 +37,7 @@ export const AdminOrgsTable: FC = ({ orgs, onDelete }) => { icon="times" onClick={() => setDeleteOrg(org)} aria-label="Delete org" + disabled={!canDeleteOrgs} /> diff --git a/public/app/features/admin/UpgradePage.tsx b/public/app/features/admin/UpgradePage.tsx index 3eb861780f043..7f33d88cbf769 100644 --- a/public/app/features/admin/UpgradePage.tsx +++ b/public/app/features/admin/UpgradePage.tsx @@ -18,10 +18,6 @@ export const UpgradePage: React.FC = ({ navModel }) => { - ); diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx index f58bf24f3c2e7..8cff0b30c8c10 100644 --- a/public/app/features/admin/UserOrgs.tsx +++ b/public/app/features/admin/UserOrgs.tsx @@ -185,7 +185,7 @@ class UnThemedOrgRow extends PureComponent { orgId={org.orgId} builtInRole={org.role} onBuiltinRoleChange={this.onBuiltinRoleChange} - disabled={rolePickerDisabled} + builtinRolesDisabled={rolePickerDisabled} /> {isExternalUser && } @@ -391,7 +391,8 @@ const ExternalUserTooltip: React.FC = () => { placement="right-end" content={
    - This user's role is not editable because it is synchronized from your auth provider. Refer to the  + This user's built-in role is not editable because it is synchronized from your auth provider. Refer to + the  {
    {this.renderExpandCollapse()}
    - - Copy to Clipboard - + + Copy to Clipboard +
    diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index cc1effb81d1b8..bb237b2583468 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -58,6 +58,10 @@ const AmRoutes: FC = () => { useCleanup((state) => state.unifiedAlerting.saveAMConfig); const handleSave = (data: Partial) => { + if (!result) { + return; + } + const newData = formAmRouteToAmRoute( alertManagerSourceName, { diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index f76a4f86fe7a7..bf10c1bc7b41f 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -25,6 +25,7 @@ import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasour import { DataSourceApi } from '@grafana/data'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PromOptions } from 'app/plugins/datasource/prometheus/types'; +import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; jest.mock('./api/prometheus'); jest.mock('./api/ruler'); @@ -308,7 +309,7 @@ describe('PanelAlertTabContent', () => { hide: false, type: 'classic_conditions', datasource: { - type: 'grafana-expression', + type: ExpressionDatasourceRef.type, uid: '-100', }, conditions: [ diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index ad4da643f667a..c87a92581a564 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -17,6 +17,8 @@ import userEvent from '@testing-library/user-event'; jest.mock('./api/alertmanager'); +const TEST_TIMEOUT = 60000; + const mocks = { api: { fetchSilences: typeAsJestMock(fetchSilences), @@ -99,53 +101,65 @@ describe('Silences', () => { setDataSourceSrv(new MockDataSourceSrv(dataSources)); }); - it('loads and shows silences', async () => { - renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - - expect(ui.silencesTable.query()).not.toBeNull(); - - const silences = ui.silenceRow.queryAll(); - expect(silences).toHaveLength(2); - expect(silences[0]).toHaveTextContent('foo=bar'); - expect(silences[1]).toHaveTextContent('foo!=bar'); - }); + it( + 'loads and shows silences', + async () => { + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + expect(ui.silencesTable.query()).not.toBeNull(); + + const silences = ui.silenceRow.queryAll(); + expect(silences).toHaveLength(2); + expect(silences[0]).toHaveTextContent('foo=bar'); + expect(silences[1]).toHaveTextContent('foo!=bar'); + }, + TEST_TIMEOUT + ); - it('shows the correct number of silenced alerts', async () => { - mocks.api.fetchAlerts.mockImplementation(() => { - return Promise.resolve([ - mockAlertmanagerAlert({ - labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - mockAlertmanagerAlert({ - labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - ]); - }); - - renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - - const silencedAlertRows = ui.silencedAlertCell.getAll(ui.silencesTable.get()); - expect(silencedAlertRows).toHaveLength(2); - expect(silencedAlertRows[0]).toHaveTextContent('2'); - expect(silencedAlertRows[1]).toHaveTextContent('0'); - }); + it( + 'shows the correct number of silenced alerts', + async () => { + mocks.api.fetchAlerts.mockImplementation(() => { + return Promise.resolve([ + mockAlertmanagerAlert({ + labels: { foo: 'bar', buzz: 'bazz' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + mockAlertmanagerAlert({ + labels: { foo: 'bar', buzz: 'bazz' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + ]); + }); + + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + const silencedAlertRows = ui.silencedAlertCell.getAll(ui.silencesTable.get()); + expect(silencedAlertRows).toHaveLength(2); + expect(silencedAlertRows[0]).toHaveTextContent('2'); + expect(silencedAlertRows[1]).toHaveTextContent('0'); + }, + TEST_TIMEOUT + ); - it('filters silences by matchers', async () => { - renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + it( + 'filters silences by matchers', + async () => { + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - const queryBar = ui.queryBar.get(); - userEvent.paste(queryBar, 'foo=bar'); + const queryBar = ui.queryBar.get(); + userEvent.paste(queryBar, 'foo=bar'); - await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(1)); - }); + await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(1)); + }, + TEST_TIMEOUT + ); }); describe('Silence edit', () => { @@ -157,94 +171,103 @@ describe('Silence edit', () => { setDataSourceSrv(new MockDataSourceSrv(dataSources)); }); - it('prefills the matchers field with matchers params', async () => { - renderSilences( - `${baseUrlPath}?matchers=${encodeURIComponent('foo=bar,bar=~ba.+,hello!=world,cluster!~us-central.*')}` - ); - await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + it( + 'prefills the matchers field with matchers params', + async () => { + const matchersParams = ['foo=bar', 'bar=~ba.+', 'hello!=world', 'cluster!~us-central.*']; + const matchersQueryString = matchersParams.map((matcher) => `matcher=${encodeURIComponent(matcher)}`).join('&'); - const matchers = ui.editor.matchersField.queryAll(); - expect(matchers).toHaveLength(4); + renderSilences(`${baseUrlPath}?${matchersQueryString}`); + await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); - expect(ui.editor.matcherName.query(matchers[0])).toHaveValue('foo'); - expect(ui.editor.matcherOperator(MatcherOperator.equal).query(matchers[0])).not.toBeNull(); - expect(ui.editor.matcherValue.query(matchers[0])).toHaveValue('bar'); + const matchers = ui.editor.matchersField.queryAll(); + expect(matchers).toHaveLength(4); - expect(ui.editor.matcherName.query(matchers[1])).toHaveValue('bar'); - expect(ui.editor.matcherOperator(MatcherOperator.regex).query(matchers[1])).not.toBeNull(); - expect(ui.editor.matcherValue.query(matchers[1])).toHaveValue('ba.+'); + expect(ui.editor.matcherName.query(matchers[0])).toHaveValue('foo'); + expect(ui.editor.matcherOperator(MatcherOperator.equal).query(matchers[0])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[0])).toHaveValue('bar'); - expect(ui.editor.matcherName.query(matchers[2])).toHaveValue('hello'); - expect(ui.editor.matcherOperator(MatcherOperator.notEqual).query(matchers[2])).not.toBeNull(); - expect(ui.editor.matcherValue.query(matchers[2])).toHaveValue('world'); + expect(ui.editor.matcherName.query(matchers[1])).toHaveValue('bar'); + expect(ui.editor.matcherOperator(MatcherOperator.regex).query(matchers[1])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[1])).toHaveValue('ba.+'); - expect(ui.editor.matcherName.query(matchers[3])).toHaveValue('cluster'); - expect(ui.editor.matcherOperator(MatcherOperator.notRegex).query(matchers[3])).not.toBeNull(); - expect(ui.editor.matcherValue.query(matchers[3])).toHaveValue('us-central.*'); - }); + expect(ui.editor.matcherName.query(matchers[2])).toHaveValue('hello'); + expect(ui.editor.matcherOperator(MatcherOperator.notEqual).query(matchers[2])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[2])).toHaveValue('world'); - it('creates a new silence', async () => { - renderSilences(baseUrlPath); - await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); - - const start = new Date(); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); - - const startDateString = dateTime(start).format('YYYY-MM-DD'); - const endDateString = dateTime(end).format('YYYY-MM-DD'); - - userEvent.clear(ui.editor.durationInput.get()); - userEvent.type(ui.editor.durationInput.get(), '1d'); - - await waitFor(() => expect(ui.editor.durationInput.query()).toHaveValue('1d')); - await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(startDateString)); - await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(endDateString)); - - userEvent.type(ui.editor.matcherName.get(), 'foo'); - userEvent.type(ui.editor.matcherOperatorSelect.get(), '='); - userEvent.tab(); - userEvent.type(ui.editor.matcherValue.get(), 'bar'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - userEvent.click(ui.editor.addMatcherButton.get(), undefined, { skipPointerEventsCheck: true }); - userEvent.type(ui.editor.matcherName.getAll()[1], 'bar'); - userEvent.type(ui.editor.matcherOperatorSelect.getAll()[1], '!='); - userEvent.tab(); - userEvent.type(ui.editor.matcherValue.getAll()[1], 'buzz'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - userEvent.click(ui.editor.addMatcherButton.get(), undefined, { skipPointerEventsCheck: true }); - userEvent.type(ui.editor.matcherName.getAll()[2], 'region'); - userEvent.type(ui.editor.matcherOperatorSelect.getAll()[2], '=~'); - userEvent.tab(); - userEvent.type(ui.editor.matcherValue.getAll()[2], 'us-west-.*'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - userEvent.click(ui.editor.addMatcherButton.get(), undefined, { skipPointerEventsCheck: true }); - userEvent.type(ui.editor.matcherName.getAll()[3], 'env'); - userEvent.type(ui.editor.matcherOperatorSelect.getAll()[3], '!~'); - userEvent.tab(); - userEvent.type(ui.editor.matcherValue.getAll()[3], 'dev|staging'); - - userEvent.type(ui.editor.comment.get(), 'Test'); - userEvent.type(ui.editor.createdBy.get(), 'Homer Simpson'); - - userEvent.click(ui.editor.submit.get()); - - await waitFor(() => - expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( - 'grafana', - expect.objectContaining({ - comment: 'Test', - createdBy: 'Homer Simpson', - matchers: [ - { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, - { isEqual: false, isRegex: false, name: 'bar', value: 'buzz' }, - { isEqual: true, isRegex: true, name: 'region', value: 'us-west-.*' }, - { isEqual: false, isRegex: true, name: 'env', value: 'dev|staging' }, - ], - }) - ) - ); - }); + expect(ui.editor.matcherName.query(matchers[3])).toHaveValue('cluster'); + expect(ui.editor.matcherOperator(MatcherOperator.notRegex).query(matchers[3])).not.toBeNull(); + expect(ui.editor.matcherValue.query(matchers[3])).toHaveValue('us-central.*'); + }, + TEST_TIMEOUT + ); + + it( + 'creates a new silence', + async () => { + renderSilences(baseUrlPath); + await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + + const start = new Date(); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + + const startDateString = dateTime(start).format('YYYY-MM-DD'); + const endDateString = dateTime(end).format('YYYY-MM-DD'); + + userEvent.clear(ui.editor.durationInput.get()); + userEvent.type(ui.editor.durationInput.get(), '1d'); + + await waitFor(() => expect(ui.editor.durationInput.query()).toHaveValue('1d')); + await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(startDateString)); + await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(endDateString)); + + userEvent.type(ui.editor.matcherName.get(), 'foo'); + userEvent.type(ui.editor.matcherOperatorSelect.get(), '='); + userEvent.tab(); + userEvent.type(ui.editor.matcherValue.get(), 'bar'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + userEvent.click(ui.editor.addMatcherButton.get(), undefined, { skipPointerEventsCheck: true }); + userEvent.type(ui.editor.matcherName.getAll()[1], 'bar'); + userEvent.type(ui.editor.matcherOperatorSelect.getAll()[1], '!='); + userEvent.tab(); + userEvent.type(ui.editor.matcherValue.getAll()[1], 'buzz'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + userEvent.click(ui.editor.addMatcherButton.get(), undefined, { skipPointerEventsCheck: true }); + userEvent.type(ui.editor.matcherName.getAll()[2], 'region'); + userEvent.type(ui.editor.matcherOperatorSelect.getAll()[2], '=~'); + userEvent.tab(); + userEvent.type(ui.editor.matcherValue.getAll()[2], 'us-west-.*'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + userEvent.click(ui.editor.addMatcherButton.get(), undefined, { skipPointerEventsCheck: true }); + userEvent.type(ui.editor.matcherName.getAll()[3], 'env'); + userEvent.type(ui.editor.matcherOperatorSelect.getAll()[3], '!~'); + userEvent.tab(); + userEvent.type(ui.editor.matcherValue.getAll()[3], 'dev|staging'); + + userEvent.type(ui.editor.comment.get(), 'Test'); + userEvent.type(ui.editor.createdBy.get(), 'Homer Simpson'); + + userEvent.click(ui.editor.submit.get()); + + await waitFor(() => + expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( + 'grafana', + expect.objectContaining({ + comment: 'Test', + createdBy: 'Homer Simpson', + matchers: [ + { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, + { isEqual: false, isRegex: false, name: 'bar', value: 'buzz' }, + { isEqual: true, isRegex: true, name: 'region', value: 'us-west-.*' }, + { isEqual: false, isRegex: true, name: 'env', value: 'dev|staging' }, + ], + }) + ) + ); + }, + TEST_TIMEOUT + ); }); diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 0ea54d1121b12..25cd0320f5624 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -1,22 +1,22 @@ -import { lastValueFrom } from 'rxjs'; import { urlUtil } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; - import { AlertmanagerAlert, AlertManagerCortexConfig, AlertmanagerGroup, - Silence, - SilenceCreatePayload, - Matcher, AlertmanagerStatus, + ExternalAlertmanagersResponse, + Matcher, Receiver, + Silence, + SilenceCreatePayload, + TestReceiversAlert, TestReceiversPayload, TestReceiversResult, - TestReceiversAlert, - ExternalAlertmanagersResponse, } from 'app/plugins/datasource/alertmanager/types'; +import { lastValueFrom } from 'rxjs'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { isFetchError } from '../utils/alertmanager'; // "grafana" for grafana-managed, otherwise a datasource name export async function fetchAlertManagerConfig(alertManagerSourceName: string): Promise { @@ -171,28 +171,55 @@ export async function testReceivers( receivers, alert, }; - const result = await lastValueFrom( - getBackendSrv().fetch({ - method: 'POST', - data, - url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/receivers/test`, - showErrorAlert: false, - showSuccessAlert: false, - }) + try { + const result = await lastValueFrom( + getBackendSrv().fetch({ + method: 'POST', + data, + url: `/api/alertmanager/${getDatasourceAPIId(alertManagerSourceName)}/config/api/v1/receivers/test`, + showErrorAlert: false, + showSuccessAlert: false, + }) + ); + + if (receiversResponseContainsErrors(result.data)) { + throw new Error(getReceiverResultError(result.data)); + } + } catch (error) { + if (isFetchError(error) && isTestReceiversResult(error.data) && receiversResponseContainsErrors(error.data)) { + throw new Error(getReceiverResultError(error.data)); + } + + throw error; + } +} + +function receiversResponseContainsErrors(result: TestReceiversResult) { + return result.receivers.some((receiver) => + receiver.grafana_managed_receiver_configs.some((config) => config.status === 'failed') ); +} - // api returns 207 if one or more receivers has failed test. Collect errors in this case - if (result.status === 207) { - throw new Error( - result.data.receivers - .flatMap((receiver) => - receiver.grafana_managed_receiver_configs - .filter((receiver) => receiver.status === 'failed') - .map((receiver) => receiver.error ?? 'Unknown error.') - ) - .join('; ') +function isTestReceiversResult(data: any): data is TestReceiversResult { + const receivers = data?.receivers; + + if (Array.isArray(receivers)) { + return receivers.every( + (receiver: any) => typeof receiver.name === 'string' && Array.isArray(receiver.grafana_managed_receiver_configs) ); } + + return false; +} + +function getReceiverResultError(receiversResult: TestReceiversResult) { + return receiversResult.receivers + .flatMap((receiver) => + receiver.grafana_managed_receiver_configs + .filter((receiver) => receiver.status === 'failed') + .map((receiver) => receiver.error ?? 'Unknown error.') + ) + .join('; '); } export async function addAlertManagers(alertManagers: string[]): Promise { diff --git a/public/app/features/alerting/unified/api/annotations.test.ts b/public/app/features/alerting/unified/api/annotations.test.ts new file mode 100644 index 0000000000000..ca52b08ca65d0 --- /dev/null +++ b/public/app/features/alerting/unified/api/annotations.test.ts @@ -0,0 +1,18 @@ +import '@grafana/runtime'; +import { fetchAnnotations } from './annotations'; + +const get = jest.fn(); + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ get }), +})); + +describe('annotations', () => { + beforeEach(() => get.mockClear()); + + it('should fetch annotation for an alertId', () => { + const ALERT_ID = 'abc123'; + fetchAnnotations(ALERT_ID); + expect(get).toBeCalledWith('/api/annotations', { alertId: ALERT_ID }); + }); +}); diff --git a/public/app/features/alerting/unified/api/annotations.ts b/public/app/features/alerting/unified/api/annotations.ts new file mode 100644 index 0000000000000..2df5ee2cacd54 --- /dev/null +++ b/public/app/features/alerting/unified/api/annotations.ts @@ -0,0 +1,8 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { StateHistoryItem } from 'app/types/unified-alerting'; + +export function fetchAnnotations(alertId: string): Promise { + return getBackendSrv().get('/api/annotations', { + alertId, + }); +} diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index f215ffe4952c0..ac3edeacf1cc2 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -1,10 +1,15 @@ import { lastValueFrom } from 'rxjs'; -import { getBackendSrv } from '@grafana/runtime'; +import { FetchResponse, getBackendSrv } from '@grafana/runtime'; import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; +interface ErrorResponseMessage { + message?: string; + error?: string; +} + // upsert a rule group. use this to update rules export async function setRulerRuleGroup( dataSourceName: string, @@ -97,28 +102,48 @@ async function rulerGetRequest(url: string, empty: T, params?: Record { + const hasErrorMessage = (error as FetchResponse).data != null; + const hasErrorCode = Number.isFinite((error as FetchResponse).status); + return hasErrorCode && hasErrorMessage; +} + +function isRulerNotSupported(error: FetchResponse) { + return ( + error.status === 404 || + (error.status === 500 && + error.data.message?.includes('unexpected content type from upstream. expected YAML, got text/html')) + ); +} + +function isCortexErrorResponse(error: FetchResponse) { + return ( + error.status === 404 && + (error.data.message?.includes('group does not exist') || error.data.message?.includes('no rule groups found')) + ); +} + export async function deleteNamespace(dataSourceName: string, namespace: string): Promise { await lastValueFrom( getBackendSrv().fetch({ diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx index ec78d1c79c1ec..662d97a1c5da9 100644 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx @@ -55,7 +55,7 @@ export default function AlertmanagerConfig(): JSX.Element { const loading = isDeleting || isLoadingConfig || isSaving; const onSubmit = (values: FormValues) => { - if (alertManagerSourceName) { + if (alertManagerSourceName && config) { dispatch( updateAlertManagerConfigAction({ newConfig: JSON.parse(values.configJSON), diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx index 8b16461f7d0c3..77e83600c5b5c 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx @@ -2,11 +2,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { LinkButton, useStyles2 } from '@grafana/ui'; import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types'; - import React, { FC } from 'react'; -import { makeAMLink } from '../../utils/misc'; +import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc'; import { AnnotationDetailsField } from '../AnnotationDetailsField'; -import { getMatcherQueryParams } from '../../utils/matchers'; interface AmNotificationsAlertDetailsProps { alertManagerSourceName: string; @@ -33,9 +31,7 @@ export const AlertDetails: FC = ({ alert, aler )} {alert.status.state === AlertState.Active && ( , 'options'> & { includeNoData: boolean; + includeError: boolean; }; const options: SelectableValue[] = [ { value: GrafanaAlertStateDecision.Alerting, label: 'Alerting' }, { value: GrafanaAlertStateDecision.NoData, label: 'No Data' }, { value: GrafanaAlertStateDecision.OK, label: 'OK' }, + { value: GrafanaAlertStateDecision.Error, label: 'Error' }, ]; -export const GrafanaAlertStatePicker: FC = ({ includeNoData, ...props }) => { +export const GrafanaAlertStatePicker: FC = ({ includeNoData, includeError, ...props }) => { const opts = useMemo(() => { - if (includeNoData) { - return options; + if (!includeNoData) { + return options.filter((opt) => opt.value !== GrafanaAlertStateDecision.NoData); } - return options.filter((opt) => opt.value !== GrafanaAlertStateDecision.NoData); - }, [includeNoData]); + if (!includeError) { + return options.filter((opt) => opt.value !== GrafanaAlertStateDecision.Error); + } + return options; + }, [includeNoData, includeError]); return
    @@ -30,7 +29,6 @@ const SilencedAlertsTable: FC = ({ silencedAlerts }) => { - diff --git a/public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx b/public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx index 9cdb73ec3d1da..3b14a25436ba8 100644 --- a/public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx @@ -1,9 +1,6 @@ import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types'; import React, { FC, useState } from 'react'; import { CollapseToggle } from '../CollapseToggle'; -import { ActionIcon } from '../rules/ActionIcon'; -import { getAlertTableStyles } from '../../styles/table'; -import { useStyles2 } from '@grafana/ui'; import { intervalToAbbreviatedDurationString } from '@grafana/data'; import { AlertLabels } from '../AlertLabels'; import { AmAlertStateTag } from './AmAlertStateTag'; @@ -15,7 +12,6 @@ interface Props { export const SilencedAlertsTableRow: FC = ({ alert, className }) => { const [isCollapsed, setIsCollapsed] = useState(true); - const tableStyles = useStyles2(getAlertTableStyles); const duration = intervalToAbbreviatedDurationString({ start: new Date(alert.startsAt), @@ -38,9 +34,6 @@ export const SilencedAlertsTableRow: FC = ({ alert, className }) => { - {!isCollapsed && ( diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index a2bb9c45aa565..e3d7b46499e38 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -8,7 +8,6 @@ import { addDurationToDate, dateTime, isValidDate, - UrlQueryMap, GrafanaTheme2, } from '@grafana/data'; import { useDebounce } from 'react-use'; @@ -24,35 +23,34 @@ import { css, cx } from '@emotion/css'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { makeAMLink } from '../../utils/misc'; import { useCleanup } from 'app/core/hooks/useCleanup'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { parseQueryParamMatchers } from '../../utils/matchers'; import { matcherToMatcherField, matcherFieldToMatcher } from '../../utils/alertmanager'; +import { useURLSearchParams } from '../../hooks/useURLSearchParams'; interface Props { silence?: Silence; alertManagerSourceName: string; } -const defaultsFromQuery = (queryParams: UrlQueryMap): Partial => { +const defaultsFromQuery = (searchParams: URLSearchParams): Partial => { const defaults: Partial = {}; - const { matchers, comment } = queryParams; + const comment = searchParams.get('comment'); + const matchers = searchParams.getAll('matcher'); - if (typeof matchers === 'string') { - const formMatchers = parseQueryParamMatchers(matchers); - if (formMatchers.length) { - defaults.matchers = formMatchers.map(matcherToMatcherField); - } + const formMatchers = parseQueryParamMatchers(matchers); + if (formMatchers.length) { + defaults.matchers = formMatchers.map(matcherToMatcherField); } - if (typeof comment === 'string') { + if (comment) { defaults.comment = comment; } return defaults; }; -const getDefaultFormValues = (queryParams: UrlQueryMap, silence?: Silence): SilenceFormFields => { +const getDefaultFormValues = (searchParams: URLSearchParams, silence?: Silence): SilenceFormFields => { const now = new Date(); if (silence) { const isExpired = Date.parse(silence.endsAt) < Date.now(); @@ -89,14 +87,15 @@ const getDefaultFormValues = (queryParams: UrlQueryMap, silence?: Silence): Sile matcherName: '', matcherValue: '', timeZone: DefaultTimeZone, - ...defaultsFromQuery(queryParams), + ...defaultsFromQuery(searchParams), }; } }; export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) => { - const [queryParams] = useQueryParams(); - const defaultValues = useMemo(() => getDefaultFormValues(queryParams, silence), [silence, queryParams]); + const [urlSearchParams] = useURLSearchParams(); + + const defaultValues = useMemo(() => getDefaultFormValues(urlSearchParams, silence), [silence, urlSearchParams]); const formAPI = useForm({ defaultValues }); const dispatch = useDispatch(); const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts b/public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts new file mode 100644 index 0000000000000..8fcb6a2201ebf --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useManagedAlertStateHistory.ts @@ -0,0 +1,19 @@ +import { StateHistoryItem } from 'app/types/unified-alerting'; +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { fetchGrafanaAnnotationsAction } from '../state/actions'; +import { AsyncRequestState } from '../utils/redux'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; + +export function useManagedAlertStateHistory(alertId: string) { + const dispatch = useDispatch(); + const history = useUnifiedAlertingSelector>( + (state) => state.managedAlertStateHistory + ); + + useEffect(() => { + dispatch(fetchGrafanaAnnotationsAction(alertId)); + }, [dispatch, alertId]); + + return history; +} diff --git a/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx new file mode 100644 index 0000000000000..980977e60e55d --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useStateHistoryModal.tsx @@ -0,0 +1,30 @@ +import React, { useMemo, useState } from 'react'; +import { Modal } from '@grafana/ui'; +import { StateHistory } from '../components/rules/StateHistory'; + +function useStateHistoryModal(alertId: string) { + const [showModal, setShowModal] = useState(false); + + const StateHistoryModal = useMemo( + () => ( + setShowModal(false)} + closeOnBackdropClick={true} + closeOnEscape={true} + title="State history" + > + + + ), + [alertId, showModal] + ); + + return { + StateHistoryModal, + showStateHistoryModal: () => setShowModal(true), + hideStateHistoryModal: () => setShowModal(false), + }; +} + +export { useStateHistoryModal }; diff --git a/public/app/features/alerting/unified/hooks/useURLSearchParams.ts b/public/app/features/alerting/unified/hooks/useURLSearchParams.ts new file mode 100644 index 0000000000000..4a9ecf08225e7 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useURLSearchParams.ts @@ -0,0 +1,8 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +export function useURLSearchParams(): [URLSearchParams] { + const { search } = useLocation(); + const queryParams = useMemo(() => new URLSearchParams(search), [search]); + return [queryParams]; +} diff --git a/public/app/features/alerting/unified/mocks/grafana-notifiers.ts b/public/app/features/alerting/unified/mocks/grafana-notifiers.ts index 8bac7507d4a76..876672ed19bf8 100644 --- a/public/app/features/alerting/unified/mocks/grafana-notifiers.ts +++ b/public/app/features/alerting/unified/mocks/grafana-notifiers.ts @@ -81,6 +81,19 @@ export const grafanaNotifiersMock: NotifierDTO[] = [ validationRule: '', secure: false, }, + { + element: 'textarea', + inputType: '', + label: 'Message', + description: '', + placeholder: '{{ template "default.message" . }}', + propertyName: 'message', + selectOptions: null, + showWhen: { field: '', is: '' }, + required: false, + validationRule: '', + secure: false, + }, ], }, { diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 7a4ba99dd5853..5451393728ed2 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -11,7 +11,7 @@ import { TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; import { FolderDTO, NotifierDTO, ThunkResult } from 'app/types'; -import { RuleIdentifier, RuleNamespace, RuleWithLocation } from 'app/types/unified-alerting'; +import { RuleIdentifier, RuleNamespace, RuleWithLocation, StateHistoryItem } from 'app/types/unified-alerting'; import { PostableRulerRuleGroupDTO, RulerGrafanaRuleDTO, @@ -19,6 +19,7 @@ import { RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; import { fetchNotifiers } from '../api/grafana'; +import { fetchAnnotations } from '../api/annotations'; import { expireSilence, fetchAlertManagerConfig, @@ -52,7 +53,7 @@ import { isVanillaPrometheusAlertManagerDataSource, } from '../utils/datasource'; import { makeAMLink, retryWhile } from '../utils/misc'; -import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux'; +import { withAppEvents, withSerializedError } from '../utils/redux'; import { formValuesToRulerRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form'; import { isCloudRuleIdentifier, @@ -61,10 +62,11 @@ import { isPrometheusRuleIdentifier, isRulerNotSupportedResponse, } from '../utils/rules'; -import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; +import { addDefaultsToAlertmanagerConfig, isFetchError } from '../utils/alertmanager'; import * as ruleId from '../utils/rule-id'; import { isEmpty } from 'lodash'; import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; +import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000; @@ -446,6 +448,11 @@ export const fetchGrafanaNotifiersAction = createAsyncThunk( (): Promise => withSerializedError(fetchNotifiers()) ); +export const fetchGrafanaAnnotationsAction = createAsyncThunk( + 'unifiedalerting/fetchGrafanaAnnotations', + (alertId: string): Promise => withSerializedError(fetchAnnotations(alertId)) +); + interface UpdateAlertManagerConfigActionOptions { alertManagerSourceName: string; oldConfig: AlertManagerCortexConfig; // it will be checked to make sure it didn't change in the meanwhile @@ -618,7 +625,8 @@ export const checkIfLotexSupportsEditingRulesAction = createAsyncThunk; diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts index a349e14960c78..e6dc39b1d331b 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.test.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -1,6 +1,6 @@ import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { parseMatcher, parseMatchers, stringifyMatcher, labelsMatchMatchers } from './alertmanager'; +import { parseMatcher, parseMatchers, labelsMatchMatchers } from './alertmanager'; describe('Alertmanager utils', () => { describe('parseMatcher', () => { @@ -31,16 +31,23 @@ describe('Alertmanager utils', () => { }); }); - it('should parse escaped values correctly', () => { - expect(parseMatcher('foo=~"bar\\"baz\\""')).toEqual({ + // Alertmanager has some strict requirements for label values; + // we should not automatically encode or decode any values sent + // and instead let AM return any errors like (matcher value contains unescaped double quote: bar"baz") + // and allow the user to update the values to the correct format + // + // see https://github.com/prometheus/alertmanager/blob/4030e3670b359b8814aa8340ea1144f32b1f5ab3/pkg/labels/parse.go#L55-L99 + // and https://github.com/prometheus/alertmanager/blob/4030e3670b359b8814aa8340ea1144f32b1f5ab3/pkg/labels/parse.go#L101-L178 + it('should not parse escaped values', () => { + expect(parseMatcher('foo="^[a-z0-9-]{1}[a-z0-9-]{0,30}$"')).toEqual({ name: 'foo', - value: 'bar"baz"', - isRegex: true, + value: '"^[a-z0-9-]{1}[a-z0-9-]{0,30}$"', + isRegex: false, isEqual: true, }); expect(parseMatcher('foo=~bar\\"baz\\"')).toEqual({ name: 'foo', - value: 'bar"baz"', + value: 'bar\\"baz\\"', isRegex: true, isEqual: true, }); @@ -55,19 +62,6 @@ describe('Alertmanager utils', () => { }); }); - describe('stringifyMatcher', () => { - it('should stringify matcher correctly', () => { - expect( - stringifyMatcher({ - name: 'foo', - value: 'boo="bar"', - isRegex: true, - isEqual: false, - }) - ).toEqual('foo!~"boo=\\"bar\\""'); - }); - }); - describe('parseMatchers', () => { it('should parse all operators', () => { expect(parseMatchers('foo=bar, bar=~ba.+, severity!=warning, email!~@grafana.com')).toEqual([ diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 117ec24750d7b..304e06b35a029 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -4,6 +4,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { SelectableValue } from '@grafana/data'; import { getAllDataSources } from './config'; import { DataSourceType } from './datasource'; +import { FetchError } from '@grafana/runtime'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -91,22 +92,6 @@ const matcherOperators = [ MatcherOperator.equal, ]; -function unescapeMatcherValue(value: string) { - let trimmed = value.trim().replace(/\\"/g, '"'); - if (trimmed.startsWith('"') && trimmed.endsWith('"') && !trimmed.endsWith('\\"')) { - trimmed = trimmed.substr(1, trimmed.length - 2); - } - return trimmed.replace(/\\"/g, '"'); -} - -function escapeMatcherValue(value: string) { - return '"' + value.replace(/"/g, '\\"') + '"'; -} - -export function stringifyMatcher(matcher: Matcher): string { - return `${matcher.name}${matcherToOperator(matcher)}${escapeMatcherValue(matcher.value)}`; -} - export function parseMatcher(matcher: string): Matcher { const trimmed = matcher.trim(); if (trimmed.startsWith('{') && trimmed.endsWith('}')) { @@ -122,7 +107,7 @@ export function parseMatcher(matcher: string): Matcher { } const [operator, idx] = operatorsFound[0]; const name = trimmed.substr(0, idx).trim(); - const value = unescapeMatcherValue(trimmed.substr(idx + operator.length).trim()); + const value = trimmed.substr(idx + operator.length).trim(); if (!name) { throw new Error(`Invalid matcher: ${trimmed}`); } @@ -184,3 +169,7 @@ export function getAllAlertmanagerDataSources() { export function getAlertmanagerByUid(uid?: string) { return getAllAlertmanagerDataSources().find((ds) => uid === ds.uid); } + +export function isFetchError(e: unknown): e is FetchError { + return typeof e === 'object' && e !== null && 'status' in e && 'data' in e; +} diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts new file mode 100644 index 0000000000000..11cfe2f4d0ff4 --- /dev/null +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -0,0 +1,36 @@ +import { getMatcherQueryParams, parseQueryParamMatchers } from './matchers'; + +describe('Unified Alerting matchers', () => { + describe('getMatcherQueryParams tests', () => { + it('Should create an entry for each label', () => { + const params = getMatcherQueryParams({ foo: 'bar', alertname: 'TestData - No data', rule_uid: 'YNZBpGJnk' }); + + const matcherParams = params.getAll('matcher'); + + expect(matcherParams).toHaveLength(3); + expect(matcherParams).toContain('foo=bar'); + expect(matcherParams).toContain('alertname=TestData - No data'); + expect(matcherParams).toContain('rule_uid=YNZBpGJnk'); + }); + }); + + describe('parseQueryParamMatchers tests', () => { + it('Should create a matcher for each unique label-expression pair', () => { + const matchers = parseQueryParamMatchers(['alertname=TestData 1', 'rule_uid=YNZBpGJnk']); + + expect(matchers).toHaveLength(2); + expect(matchers[0].name).toBe('alertname'); + expect(matchers[0].value).toBe('TestData 1'); + expect(matchers[1].name).toBe('rule_uid'); + expect(matchers[1].value).toBe('YNZBpGJnk'); + }); + + it('Should create one matcher, using the first occurence when duplicated labels exists', () => { + const matchers = parseQueryParamMatchers(['alertname=TestData 1', 'alertname=TestData 2']); + + expect(matchers).toHaveLength(1); + expect(matchers[0].name).toBe('alertname'); + expect(matchers[0].value).toBe('TestData 1'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index fee8c2d53efe1..ebef3ca3ed5ab 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -1,22 +1,26 @@ import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from '@grafana/data'; import { parseMatcher } from './alertmanager'; +import { uniqBy } from 'lodash'; -// parses comma separated matchers like "foo=bar,baz=~bad*" into SilenceMatcher[] -export function parseQueryParamMatchers(paramValue: string): Matcher[] { - return paramValue - .split(',') - .filter((x) => !!x.trim()) - .map((x) => parseMatcher(x.trim())); +// Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[] +export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] { + const parsedMatchers = matcherPairs.filter((x) => !!x.trim()).map((x) => parseMatcher(x.trim())); + + // Due to migration, old alert rules might have a duplicated alertname label + // To handle that case want to filter out duplicates and make sure there are only unique labels + return uniqBy(parsedMatchers, (matcher) => matcher.name); } export const getMatcherQueryParams = (labels: Labels) => { - return `matchers=${encodeURIComponent( - Object.entries(labels) - .filter(([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__'))) - .map(([labelKey, labelValue]) => { - return `${labelKey}=${labelValue}`; - }) - .join(',') - )}`; + const validMatcherLabels = Object.entries(labels).filter( + ([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__')) + ); + + const matcherUrlParams = new URLSearchParams(); + validMatcherLabels.forEach(([labelKey, labelValue]) => + matcherUrlParams.append('matcher', `${labelKey}=${labelValue}`) + ); + + return matcherUrlParams; }; diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 813c3143f0cb3..64fef8596c4d2 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -1,9 +1,10 @@ -import { urlUtil, UrlQueryMap } from '@grafana/data'; +import { urlUtil, UrlQueryMap, Labels } from '@grafana/data'; import { config } from '@grafana/runtime'; import { CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting'; import { ALERTMANAGER_NAME_QUERY_KEY } from './constants'; import { getRulesSourceName } from './datasource'; import * as ruleId from './rule-id'; +import { getMatcherQueryParams } from './matchers'; export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string { const sourceName = getRulesSourceName(ruleSource); @@ -57,13 +58,23 @@ export function makeAMLink(path: string, alertManagerName?: string): string { return `${path}${alertManagerName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${encodeURIComponent(alertManagerName)}` : ''}`; } -export function makeSilenceLink(alertmanagerSourceName: string, rule: CombinedRule) { - return ( - `${config.appSubUrl}/alerting/silence/new?alertmanager=${alertmanagerSourceName}` + - `&matchers=alertname=${rule.name},${Object.entries(rule.labels) - .map(([key, value]) => encodeURIComponent(`${key}=${value}`)) - .join(',')}` - ); +export function makeRuleBasedSilenceLink(alertManagerSourceName: string, rule: CombinedRule) { + const labels: Labels = { + alertname: rule.name, + ...rule.labels, + }; + + return makeLabelBasedSilenceLink(alertManagerSourceName, labels); +} + +export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels: Labels) { + const silenceUrlParams = new URLSearchParams(); + silenceUrlParams.append('alertmanager', alertManagerSourceName); + + const matcherParams = getMatcherQueryParams(labels); + matcherParams.forEach((value, key) => silenceUrlParams.append(key, value)); + + return `${config.appSubUrl}/alerting/silence/new?${silenceUrlParams.toString()}`; } // keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet diff --git a/public/app/features/alerting/unified/utils/redux.ts b/public/app/features/alerting/unified/utils/redux.ts index 068c3c4f920d7..2d58186d0954e 100644 --- a/public/app/features/alerting/unified/utils/redux.ts +++ b/public/app/features/alerting/unified/utils/redux.ts @@ -4,6 +4,9 @@ import { FetchError } from '@grafana/runtime'; import { AppEvents } from '@grafana/data'; import { appEvents } from 'app/core/core'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { PERCONA_CANCELLED_ERROR_NAME } from 'app/percona/shared/core'; +import { isFetchError } from './alertmanager'; export interface AsyncRequestState { result?: T; @@ -13,10 +16,21 @@ export interface AsyncRequestState { requestId?: string; } -export const initialAsyncRequestState: AsyncRequestState = Object.freeze({ - loading: false, - dispatched: false, -}); +interface AsyncInitialRequestState extends AsyncRequestState { + result: T; +} + +export const createInitialAsyncRequestState = ( + state: T +): Pick, 'loading' | 'dispatched' | 'result' | 'error'> => + Object.freeze({ + loading: false, + result: state, + error: undefined, + dispatched: false, + }); + +export const initialAsyncRequestState = createInitialAsyncRequestState(undefined); export type AsyncRequestMapSlice = Record>; @@ -62,11 +76,14 @@ function requestStateReducer( */ export function createAsyncSlice( name: string, - asyncThunk: AsyncThunk + asyncThunk: AsyncThunk, + initialState?: T ) { return createSlice({ name, - initialState: initialAsyncRequestState as AsyncRequestState, + initialState: initialState + ? createInitialAsyncRequestState(initialState) + : (initialAsyncRequestState as AsyncRequestState), reducers: {}, extraReducers: (builder) => builder.addDefaultCase((state, action) => @@ -110,6 +127,7 @@ export function withSerializedError(p: Promise): Promise { const err: SerializedError = { message: messageFromError(e), code: e.statusCode, + name: isApiCancelError(e) ? PERCONA_CANCELLED_ERROR_NAME : '', }; throw err; }); @@ -133,10 +151,6 @@ export function withAppEvents( }); } -export function isFetchError(e: unknown): e is FetchError { - return typeof e === 'object' && e !== null && 'status' in e && 'data' in e; -} - export function messageFromError(e: Error | FetchError | SerializedError): string { if (isFetchError(e)) { if (e.data?.message) { diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index de408a6b3724a..bf9326f252ec7 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -30,6 +30,7 @@ import { isGrafanaRulesSource } from './datasource'; import { arrayToRecord, recordToArray } from './misc'; import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules'; import { parseInterval } from './time'; +import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; export const getDefaultFormValues = (): RuleFormValues => Object.freeze({ @@ -192,7 +193,7 @@ const getDefaultExpression = (refId: string): AlertQuery => { type: ExpressionQueryType.classic, datasource: { uid: ExpressionDatasourceUID, - type: 'grafana-expression', + type: ExpressionDatasourceRef.type, }, conditions: [ { diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 064404ed5aea2..8896e91d561c7 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -1,3 +1,4 @@ +import { AlertState } from '@grafana/data'; import { GrafanaAlertState, PromAlertingRuleState, @@ -49,7 +50,7 @@ export function alertInstanceKey(alert: Alert): string { } export function isRulerNotSupportedResponse(resp: AsyncRequestState) { - return resp.error && resp.error?.message === RULER_NOT_SUPPORTED_MSG; + return resp.error && resp.error?.message?.includes(RULER_NOT_SUPPORTED_MSG); } export function isGrafanaRuleIdentifier(identifier: RuleIdentifier): identifier is GrafanaRuleIdentifier { @@ -64,7 +65,7 @@ export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifi return 'ruleHash' in identifier; } -export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState): string { +export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertState | AlertState): string { if (state === PromAlertingRuleState.Inactive) { return 'Normal'; } @@ -84,7 +85,7 @@ export const flattenRules = (rules: RuleNamespace[]) => { }, []); }; -export const alertStateToState: Record = { +export const alertStateToState: Record = { [PromAlertingRuleState.Inactive]: 'good', [PromAlertingRuleState.Firing]: 'bad', [PromAlertingRuleState.Pending]: 'warning', @@ -93,6 +94,12 @@ export const alertStateToState: Record ({ + deleteWarning: css` + margin-top: ${spacing.lg}; + `, +}); diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx index ca7593faf43cf..e7adf743e6286 100644 --- a/public/app/features/api-keys/ApiKeysPage.test.tsx +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -14,6 +14,7 @@ const setup = (propOverrides: Partial) => { const loadApiKeysMock = jest.fn(); const deleteApiKeyMock = jest.fn(); const addApiKeyMock = jest.fn(); + const toggleIncludeExpiredMock = jest.fn(); const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery); const props: Props = { navModel: { @@ -33,21 +34,31 @@ const setup = (propOverrides: Partial) => { addApiKey: addApiKeyMock, apiKeysCount: 0, timeZone: 'utc', + includeExpired: false, + includeExpiredDisabled: false, + toggleIncludeExpired: toggleIncludeExpiredMock, }; Object.assign(props, propOverrides); const { rerender } = render(); - return { rerender, props, loadApiKeysMock, setSearchQueryMock, deleteApiKeyMock, addApiKeyMock }; + return { + rerender, + props, + loadApiKeysMock, + setSearchQueryMock, + deleteApiKeyMock, + addApiKeyMock, + toggleIncludeExpiredMock, + }; }; describe('ApiKeysPage', () => { silenceConsoleOutput(); describe('when mounted', () => { - it('then it should call loadApiKeys without expired', () => { + it('then it should call loadApiKeys', () => { const { loadApiKeysMock } = setup({}); expect(loadApiKeysMock).toHaveBeenCalledTimes(1); - expect(loadApiKeysMock).toHaveBeenCalledWith(false); }); }); @@ -82,19 +93,12 @@ describe('ApiKeysPage', () => { }); describe('when a user toggles the Show expired toggle', () => { - it('then it should call loadApiKeys with correct parameters', async () => { + it('then it should dispatch toggleIncludeExpired', async () => { const apiKeys = getMultipleMockKeys(3); - const { loadApiKeysMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + const { toggleIncludeExpiredMock } = 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); + expect(toggleIncludeExpiredMock).toHaveBeenCalledTimes(1); }); }); @@ -128,7 +132,7 @@ describe('ApiKeysPage', () => { 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); + expect(deleteApiKeyMock).toHaveBeenCalledWith(1); toggleShowExpired(); @@ -140,7 +144,7 @@ describe('ApiKeysPage', () => { skipPointerEventsCheck: true, }); expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); - expect(deleteApiKeyMock).toHaveBeenCalledWith(2, true); + expect(deleteApiKeyMock).toHaveBeenCalledWith(2); }); }); @@ -151,7 +155,7 @@ describe('ApiKeysPage', () => { addApiKeyMock.mockClear(); userEvent.click(screen.getByTestId(selectors.components.CallToActionCard.buttonV2('New API key'))); - await addAndVerifyApiKey(addApiKeyMock, false); + await addAndVerifyApiKey(addApiKeyMock); }); }); @@ -162,13 +166,13 @@ describe('ApiKeysPage', () => { addApiKeyMock.mockClear(); userEvent.click(screen.getByRole('button', { name: /add api key/i })); - await addAndVerifyApiKey(addApiKeyMock, false); + await addAndVerifyApiKey(addApiKeyMock); toggleShowExpired(); addApiKeyMock.mockClear(); userEvent.click(screen.getByRole('button', { name: /add api key/i })); - await addAndVerifyApiKey(addApiKeyMock, true); + await addAndVerifyApiKey(addApiKeyMock); }); }); @@ -190,11 +194,11 @@ describe('ApiKeysPage', () => { }); function toggleShowExpired() { - expect(screen.queryByLabelText(/show expired/i)).toBeInTheDocument(); - userEvent.click(screen.getByLabelText(/show expired/i)); + expect(screen.queryByLabelText(/include expired keys/i)).toBeInTheDocument(); + userEvent.click(screen.getByLabelText(/include expired keys/i)); } -async function addAndVerifyApiKey(addApiKeyMock: jest.Mock, includeExpired: boolean) { +async function addAndVerifyApiKey(addApiKeyMock: jest.Mock) { expect(screen.getByRole('heading', { name: /add api key/i })).toBeInTheDocument(); expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/1d/i)).toBeInTheDocument(); @@ -204,9 +208,5 @@ async function addAndVerifyApiKey(addApiKeyMock: jest.Mock, includeExpired: bool 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 - ); + expect(addApiKeyMock).toHaveBeenCalledWith({ name: 'Test', role: 'Viewer', secondsToLive: 60 }, expect.anything()); } diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 230b70a03c5d2..ac8ab9151da22 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -3,8 +3,8 @@ import { connect, ConnectedProps } from 'react-redux'; // 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 { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './state/selectors'; +import { addApiKey, deleteApiKey, loadApiKeys, toggleIncludeExpired } from './state/actions'; import Page from 'app/core/components/Page/Page'; import { ApiKeysAddedModal } from './ApiKeysAddedModal'; import config from 'app/core/config'; @@ -19,6 +19,9 @@ import { ApiKeysActionBar } from './ApiKeysActionBar'; import { ApiKeysTable } from './ApiKeysTable'; import { ApiKeysController } from './ApiKeysController'; import { ShowModalReactEvent } from 'app/types/events'; +import { WarningBlock } from 'app/percona/shared/components/Elements/WarningBlock'; +import { Messages } from './ApiKeys.messages'; +import { getStyles } from './ApiKeys.styles'; function mapStateToProps(state: StoreState) { return { @@ -28,6 +31,8 @@ function mapStateToProps(state: StoreState) { apiKeysCount: getApiKeysCount(state.apiKeys), hasFetched: state.apiKeys.hasFetched, timeZone: getTimeZone(state.user), + includeExpired: getIncludeExpired(state.apiKeys), + includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys), }; } @@ -35,6 +40,7 @@ const mapDispatchToProps = { loadApiKeys, deleteApiKey, setSearchQuery, + toggleIncludeExpired, addApiKey, }; @@ -45,14 +51,12 @@ interface OwnProps {} export type Props = OwnProps & ConnectedProps; interface State { - includeExpired: boolean; - hasFetched: boolean; + isAdding: boolean; } export class ApiKeysPageUnconnected extends PureComponent { constructor(props: Props) { super(props); - this.state = { includeExpired: false, hasFetched: false }; } componentDidMount() { @@ -60,11 +64,11 @@ export class ApiKeysPageUnconnected extends PureComponent { } async fetchApiKeys() { - await this.props.loadApiKeys(this.state.includeExpired); + await this.props.loadApiKeys(); } onDeleteApiKey = (key: ApiKey) => { - this.props.deleteApiKey(key.id!, this.state.includeExpired); + this.props.deleteApiKey(key.id!); }; onSearchQueryChange = (value: string) => { @@ -72,7 +76,7 @@ export class ApiKeysPageUnconnected extends PureComponent { }; onIncludeExpiredChange = (event: React.SyntheticEvent) => { - this.setState({ hasFetched: false, includeExpired: event.currentTarget.checked }, this.fetchApiKeys); + this.props.toggleIncludeExpired(); }; onAddApiKey = (newApiKey: NewApiKey) => { @@ -97,7 +101,7 @@ export class ApiKeysPageUnconnected extends PureComponent { ...newApiKey, secondsToLive: secondsToLiveAsNumber, }; - this.props.addApiKey(apiKey, openModal, this.state.includeExpired); + this.props.addApiKey(apiKey, openModal); this.setState((prevState: State) => { return { ...prevState, @@ -110,8 +114,18 @@ export class ApiKeysPageUnconnected extends PureComponent { }; render() { - const { hasFetched, navModel, apiKeysCount, apiKeys, searchQuery, timeZone } = this.props; - const { includeExpired } = this.state; + const { + hasFetched, + navModel, + apiKeysCount, + apiKeys, + searchQuery, + timeZone, + includeExpired, + includeExpiredDisabled, + } = this.props; + + const styles = getStyles(config.theme); if (!hasFetched) { return ( @@ -150,9 +164,14 @@ export class ApiKeysPageUnconnected extends PureComponent { {showTable ? ( - + + ) : null} diff --git a/public/app/features/api-keys/ApiKeysTable.tsx b/public/app/features/api-keys/ApiKeysTable.tsx index 0f1e519ad0bb7..06445bf09e151 100644 --- a/public/app/features/api-keys/ApiKeysTable.tsx +++ b/public/app/features/api-keys/ApiKeysTable.tsx @@ -1,8 +1,9 @@ import React, { FC } from 'react'; -import { DeleteButton } from '@grafana/ui'; -import { dateTimeFormat, TimeZone } from '@grafana/data'; +import { DeleteButton, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui'; +import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; import { ApiKey } from '../../types'; +import { css } from '@emotion/css'; interface Props { apiKeys: ApiKey[]; @@ -11,6 +12,9 @@ interface Props { } export const ApiKeysTable: FC = ({ apiKeys, timeZone, onDelete }) => { + const theme = useTheme2(); + const styles = getStyles(theme); + return (
    State Alert nameActions
    for {duration} seconds {alertName} - -
    @@ -24,11 +28,21 @@ export const ApiKeysTable: FC = ({ apiKeys, timeZone, onDelete }) => { {apiKeys.length > 0 ? ( {apiKeys.map((key) => { + const isExpired = Boolean(key.expiration && Date.now() > new Date(key.expiration).getTime()); return ( - + - + @@ -47,3 +61,12 @@ function formatDate(expiration: string | undefined, timeZone: TimeZone): string } return dateTimeFormat(expiration, { timeZone }); } + +const getStyles = (theme: GrafanaTheme2) => ({ + tableRow: (isExpired: boolean) => css` + color: ${isExpired ? theme.colors.text.secondary : theme.colors.text.primary}; + `, + tooltipContainer: css` + margin-left: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts index 5256ee33c31cc..22fc829866a85 100644 --- a/public/app/features/api-keys/state/actions.ts +++ b/public/app/features/api-keys/state/actions.ts @@ -1,31 +1,37 @@ import { getBackendSrv } from 'app/core/services/backend_srv'; import { ApiKey, ThunkResult } from 'app/types'; -import { apiKeysLoaded, setSearchQuery } from './reducers'; +import { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } from './reducers'; -export function addApiKey( - apiKey: ApiKey, - openModal: (key: string) => void, - includeExpired: boolean -): ThunkResult { +export function addApiKey(apiKey: ApiKey, openModal: (key: string) => void): ThunkResult { return async (dispatch) => { const result = await getBackendSrv().post('/api/auth/keys', apiKey); dispatch(setSearchQuery('')); - dispatch(loadApiKeys(includeExpired)); + dispatch(loadApiKeys()); openModal(result.key); }; } -export function loadApiKeys(includeExpired: boolean): ThunkResult { +export function loadApiKeys(): ThunkResult { return async (dispatch) => { - const response = await getBackendSrv().get('/api/auth/keys?includeExpired=' + includeExpired); - dispatch(apiKeysLoaded(response)); + dispatch(isFetching()); + const [keys, keysIncludingExpired] = await Promise.all([ + getBackendSrv().get('/api/auth/keys?includeExpired=false'), + getBackendSrv().get('/api/auth/keys?includeExpired=true'), + ]); + dispatch(apiKeysLoaded({ keys, keysIncludingExpired })); }; } -export function deleteApiKey(id: number, includeExpired: boolean): ThunkResult { +export function deleteApiKey(id: number): ThunkResult { return async (dispatch) => { getBackendSrv() .delete(`/api/auth/keys/${id}`) - .then(() => dispatch(loadApiKeys(includeExpired))); + .then(() => dispatch(loadApiKeys())); + }; +} + +export function toggleIncludeExpired(): ThunkResult { + return (dispatch) => { + dispatch(includeExpiredToggled()); }; } diff --git a/public/app/features/api-keys/state/reducers.test.ts b/public/app/features/api-keys/state/reducers.test.ts index 4ff955936838a..655a3d98c5bfd 100644 --- a/public/app/features/api-keys/state/reducers.test.ts +++ b/public/app/features/api-keys/state/reducers.test.ts @@ -1,4 +1,11 @@ -import { apiKeysLoaded, apiKeysReducer, initialApiKeysState, setSearchQuery } from './reducers'; +import { + apiKeysLoaded, + apiKeysReducer, + includeExpiredToggled, + initialApiKeysState, + isFetching, + setSearchQuery, +} from './reducers'; import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { ApiKeysState } from '../../../types'; @@ -7,10 +14,13 @@ describe('API Keys reducer', () => { it('should set keys', () => { reducerTester() .givenReducer(apiKeysReducer, { ...initialApiKeysState }) - .whenActionIsDispatched(apiKeysLoaded(getMultipleMockKeys(4))) + .whenActionIsDispatched( + apiKeysLoaded({ keys: getMultipleMockKeys(4), keysIncludingExpired: getMultipleMockKeys(6) }) + ) .thenStateShouldEqual({ ...initialApiKeysState, keys: getMultipleMockKeys(4), + keysIncludingExpired: getMultipleMockKeys(6), hasFetched: true, }); }); @@ -24,4 +34,24 @@ describe('API Keys reducer', () => { searchQuery: 'test query', }); }); + + it('should toggle the includeExpired state', () => { + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(includeExpiredToggled()) + .thenStateShouldEqual({ + ...initialApiKeysState, + includeExpired: true, + }); + }); + + it('should set state when fetching', () => { + reducerTester() + .givenReducer(apiKeysReducer, { ...initialApiKeysState }) + .whenActionIsDispatched(isFetching()) + .thenStateShouldEqual({ + ...initialApiKeysState, + hasFetched: false, + }); + }); }); diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts index b76d268c4c6d8..327d18d3abcd1 100644 --- a/public/app/features/api-keys/state/reducers.ts +++ b/public/app/features/api-keys/state/reducers.ts @@ -3,9 +3,11 @@ import { ApiKeysState } from 'app/types'; export const initialApiKeysState: ApiKeysState = { + hasFetched: false, + includeExpired: false, keys: [], + keysIncludingExpired: [], searchQuery: '', - hasFetched: false, }; const apiKeysSlice = createSlice({ @@ -13,15 +15,26 @@ const apiKeysSlice = createSlice({ initialState: initialApiKeysState, reducers: { apiKeysLoaded: (state, action): ApiKeysState => { - return { ...state, hasFetched: true, keys: action.payload }; + const { keys, keysIncludingExpired } = action.payload; + const includeExpired = + action.payload.keys.length === 0 && action.payload.keysIncludingExpired.length > 0 + ? true + : state.includeExpired; + return { ...state, hasFetched: true, keys, keysIncludingExpired, includeExpired }; }, setSearchQuery: (state, action): ApiKeysState => { return { ...state, searchQuery: action.payload }; }, + includeExpiredToggled: (state): ApiKeysState => { + return { ...state, includeExpired: !state.includeExpired }; + }, + isFetching: (state): ApiKeysState => { + return { ...state, hasFetched: false }; + }, }, }); -export const { setSearchQuery, apiKeysLoaded } = apiKeysSlice.actions; +export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } = apiKeysSlice.actions; export const apiKeysReducer = apiKeysSlice.reducer; diff --git a/public/app/features/api-keys/state/selectors.test.ts b/public/app/features/api-keys/state/selectors.test.ts index 5e9ba51462fc2..307dadeba692f 100644 --- a/public/app/features/api-keys/state/selectors.test.ts +++ b/public/app/features/api-keys/state/selectors.test.ts @@ -1,25 +1,140 @@ -import { getApiKeys } from './selectors'; +import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './selectors'; import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; import { ApiKeysState } from 'app/types'; describe('API Keys selectors', () => { - describe('Get API Keys', () => { - const mockKeys = getMultipleMockKeys(5); + const mockKeys = getMultipleMockKeys(5); + const mockKeysIncludingExpired = getMultipleMockKeys(8); - it('should return all keys if no search query', () => { - const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false }; + describe('getApiKeysCount', () => { + it('returns the correct count when includeExpired is false', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: false, + }; + const keyCount = getApiKeysCount(mockState); + expect(keyCount).toBe(5); + }); + + it('returns the correct count when includeExpired is true', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: true, + }; + const keyCount = getApiKeysCount(mockState); + expect(keyCount).toBe(8); + }); + }); - const keys = getApiKeys(mockState); + describe('getApiKeys', () => { + describe('when includeExpired is false', () => { + it('should return all keys if no search query', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: false, + }; + const keys = getApiKeys(mockState); + expect(keys).toEqual(mockKeys); + }); - expect(keys).toEqual(mockKeys); + it('should filter keys if search query exists', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '5', + hasFetched: true, + includeExpired: false, + }; + const keys = getApiKeys(mockState); + expect(keys.length).toEqual(1); + }); }); - it('should filter keys if search query exists', () => { - const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false }; + describe('when includeExpired is true', () => { + it('should return all keys if no search query', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: true, + }; + const keys = getApiKeys(mockState); + expect(keys).toEqual(mockKeysIncludingExpired); + }); + + it('should filter keys if search query exists', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '5', + hasFetched: true, + includeExpired: true, + }; + const keys = getApiKeys(mockState); + expect(keys.length).toEqual(1); + }); + }); + }); - const keys = getApiKeys(mockState); + describe('getIncludeExpired', () => { + it('returns true if includeExpired is true', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: true, + }; + const includeExpired = getIncludeExpired(mockState); + expect(includeExpired).toBe(true); + }); + + it('returns false if includeExpired is false', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: false, + }; + const includeExpired = getIncludeExpired(mockState); + expect(includeExpired).toBe(false); + }); + }); + + describe('getIncludeExpiredDisabled', () => { + it('returns true if there are no active keys but there are expired keys', () => { + const mockState: ApiKeysState = { + keys: [], + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: true, + }; + const includeExpiredDisabled = getIncludeExpiredDisabled(mockState); + expect(includeExpiredDisabled).toBe(true); + }); - expect(keys.length).toEqual(1); + it('returns false otherwise', () => { + const mockState: ApiKeysState = { + keys: mockKeys, + keysIncludingExpired: mockKeysIncludingExpired, + searchQuery: '', + hasFetched: true, + includeExpired: false, + }; + const includeExpiredDisabled = getIncludeExpired(mockState); + expect(includeExpiredDisabled).toBe(false); }); }); }); diff --git a/public/app/features/api-keys/state/selectors.ts b/public/app/features/api-keys/state/selectors.ts index 1fbfe80fd8083..df05e85625362 100644 --- a/public/app/features/api-keys/state/selectors.ts +++ b/public/app/features/api-keys/state/selectors.ts @@ -1,11 +1,18 @@ import { ApiKeysState } from 'app/types'; -export const getApiKeysCount = (state: ApiKeysState) => state.keys.length; +export const getApiKeysCount = (state: ApiKeysState) => + state.includeExpired ? state.keysIncludingExpired.length : state.keys.length; export const getApiKeys = (state: ApiKeysState) => { const regex = RegExp(state.searchQuery, 'i'); + const keysToFilter = state.includeExpired ? state.keysIncludingExpired : state.keys; - return state.keys.filter((key) => { + return keysToFilter.filter((key) => { return regex.test(key.name) || regex.test(key.role); }); }; + +export const getIncludeExpired = (state: ApiKeysState) => state.includeExpired; + +export const getIncludeExpiredDisabled = (state: ApiKeysState) => + state.keys.length === 0 && state.keysIncludingExpired.length > 0; diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx index 623c103bd7be2..4ce64244bd9dc 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -22,7 +22,7 @@ describe('AddPanelWidget', () => { it('should render the add panel actions', () => { getTestContext(); - expect(screen.getByText(/Add an empty panel/i)).toBeInTheDocument(); + expect(screen.getByText(/Add a new panel/i)).toBeInTheDocument(); expect(screen.getByText(/Add a new row/i)).toBeInTheDocument(); expect(screen.getByText(/Add a panel from the panel library/i)).toBeInTheDocument(); }); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index bed9772068ccb..a059a67818d7e 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -4,7 +4,7 @@ import { css, cx, keyframes } from '@emotion/css'; import { chain, cloneDeep, defaults, find, sortBy } from 'lodash'; import tinycolor from 'tinycolor2'; import { locationService, reportInteraction } from '@grafana/runtime'; -import { Icon, IconButton, styleMixins, useStyles2 } from '@grafana/ui'; +import { Icon, IconButton, useStyles2 } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { GrafanaTheme2 } from '@grafana/data'; @@ -19,6 +19,7 @@ import { LibraryPanelsSearch, LibraryPanelsSearchVariant, } from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch'; +import { CardButton } from 'app/core/components/CardButton'; export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } }; @@ -145,53 +146,48 @@ export const AddPanelWidgetUnconnected: React.FC = ({ panel, dashboard }) ) : (
    -
    -
    { - reportInteraction('Create new panel'); - onCreateNewPanel(); - }} - aria-label={selectors.pages.AddDashboard.addNewPanel} - > - - Add an empty panel -
    -
    { - reportInteraction('Create new row'); - onCreateNewRow(); - }} - aria-label={selectors.pages.AddDashboard.addNewRow} - > - - Add a new row -
    -
    -
    -
    { + reportInteraction('Create new panel'); + onCreateNewPanel(); + }} + > + Add a new panel + + { + reportInteraction('Create new row'); + onCreateNewRow(); + }} + > + Add a new row + + { + reportInteraction('Add a panel from the panel library'); + setAddPanelView(true); + }} + > + Add a panel from the panel library + + {copiedPanelPlugins.length === 1 && ( + { - reportInteraction('Add a panel from the panel library'); - setAddPanelView(true); + reportInteraction('Paste panel from clipboard'); + onPasteCopiedPanel(copiedPanelPlugins[0]); }} - aria-label={selectors.pages.AddDashboard.addNewPanelLibrary} > - - Add a panel from the panel library -
    - {copiedPanelPlugins.length === 1 && ( -
    { - reportInteraction('Paste panel from clipboard'); - onPasteCopiedPanel(copiedPanelPlugins[0]); - }} - > - - Paste panel from clipboard -
    - )} -
    + Paste panel from clipboard + + )}
    )} @@ -254,45 +250,19 @@ const getStyles = (theme: GrafanaTheme2) => { box-shadow: 0 0 0 2px black, 0 0 0px 4px #1f60c4; animation: ${pulsate} 2s ease infinite; `, - rowGap: css` - margin-left: ${theme.spacing(1)}; - `, - columnGap: css` - margin-bottom: ${theme.spacing(1)}; - `, - actionsRow: css` - display: flex; - flex-direction: row; + actionsWrapper: css` height: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: ${theme.spacing(1)}; + row-gap: ${theme.spacing(1)}; + padding: ${theme.spacing(0, 1, 1, 1)}; - > div { - justify-self: center; - cursor: pointer; - background: ${theme.colors.background.secondary}; - border-radius: ${theme.shape.borderRadius(1)}; - color: ${theme.colors.text.primary}; - width: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; - - &:hover { - background: ${styleMixins.hoverColor(theme.colors.background.secondary, theme)}; - } - - &:hover > #book-icon { - background: linear-gradient(#f05a28 30%, #fbca0a 99%); - } + // This is to make the last action full width (if by itself) + & > div:nth-child(2n-1):nth-last-of-type(1) { + grid-column: span 2; } `, - actionsWrapper: css` - display: flex; - flex-direction: column; - padding: ${theme.spacing(0, 1, 1, 1)}; - height: 100%; - `, headerRow: css` display: flex; align-items: center; diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index 6602ebb6cbdca..dc928b935ebe0 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -41,6 +41,148 @@ variableAdapters.register(createQueryVariableAdapter()); variableAdapters.register(createConstantVariableAdapter()); variableAdapters.register(createDataSourceVariableAdapter()); +it('handles a default datasource in a template variable', async () => { + const dashboard: any = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + target: { + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + type: 'dashboard', + }, + ], + }, + editable: true, + fiscalYearStartMonth: 0, + graphTooltip: 0, + id: 331, + iteration: 1642157860116, + links: [], + liveNow: false, + panels: [ + { + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 9, + w: 12, + x: 0, + y: 0, + }, + id: 2, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'testdata', + uid: 'PD8C576611E62080A', + }, + expr: '{filename="/var/log/system.log"}', + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + ], + templating: { + list: [ + { + current: {}, + definition: 'test', + error: {}, + hide: 0, + includeAll: false, + multi: false, + name: 'query0', + options: [], + query: { + query: 'test', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + ], + }, + }; + const dashboardModel = new DashboardModel(dashboard, {}, () => dashboard.templating.list); + const exporter = new DashboardExporter(); + const exported: any = await exporter.makeExportable(dashboardModel); + expect(exported.templating.list[0].datasource).toBe('${DS_GFDB}'); +}); + describe('given dashboard with repeated panels', () => { let dash: any, exported: any; @@ -75,7 +217,7 @@ describe('given dashboard with repeated panels', () => { list: [ { name: 'logs', - datasource: { uid: 'gfdb', type: 'testdb' }, + datasource: 'gfdb', }, ], }, @@ -167,16 +309,16 @@ describe('given dashboard with repeated panels', () => { it('should replace datasource refs', () => { const panel = exported.panels[0]; - expect(panel.datasource).toBe('${DS_GFDB}'); + expect(panel.datasource.uid).toBe('${DS_GFDB}'); }); it('should replace datasource refs in collapsed row', () => { const panel = exported.panels[6].panels[0]; - expect(panel.datasource).toBe('${DS_GFDB}'); + expect(panel.datasource.uid).toBe('${DS_GFDB}'); }); it('should replace datasource in variable query', () => { - expect(exported.templating.list[0].datasource).toBe('${DS_GFDB}'); + expect(exported.templating.list[0].datasource.uid).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); @@ -280,8 +422,11 @@ describe('given dashboard with repeated panels', () => { expect(element.kind).toBe(LibraryElementKind.Panel); expect(element.model).toEqual({ id: 16, - datasource: '${DS_GFDB}', type: 'graph', + datasource: { + type: 'testdb', + uid: '${DS_GFDB}', + }, }); }); }); diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 9cae57c6d3202..74f321e675c1b 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -116,7 +116,11 @@ export class DashboardExporter { pluginName: ds.meta?.name, }; - obj.datasource = '${' + refName + '}'; + if (!obj.datasource || typeof obj.datasource === 'string') { + obj.datasource = '${' + refName + '}'; + } else { + obj.datasource.uid = '${' + refName + '}'; + } }) ); }; diff --git a/public/app/features/dashboard/components/Inspector/InspectContent.tsx b/public/app/features/dashboard/components/Inspector/InspectContent.tsx index 618a0959d37c3..311059c79d0cc 100644 --- a/public/app/features/dashboard/components/Inspector/InspectContent.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectContent.tsx @@ -82,6 +82,7 @@ export const InspectContent: React.FC = ({ isLoading={isDataLoading} options={dataOptions} onOptionsChange={onDataOptionsChange} + timeZone={dashboard.timezone} /> )} diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx index 9dae1eb897fbe..203c2095fc2db 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { CustomScrollbar, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { getPanelFrameCategory } from './getPanelFrameOptions'; -import { getVizualizationOptions } from './getVizualizationOptions'; +import { getVisualizationOptions } from './getVisualizationOptions'; import { css } from '@emotion/css'; import { OptionsPaneCategory } from './OptionsPaneCategory'; import { getFieldOverrideCategories } from './getFieldOverrideElements'; @@ -21,7 +21,7 @@ export const OptionsPaneOptions: React.FC = (props) => { const styles = useStyles2(getStyles); const [panelFrameOptions, vizOptions, libraryPanelOptions] = useMemo( - () => [getPanelFrameCategory(props), getVizualizationOptions(props), getLibraryPanelOptionsCategory(props)], + () => [getPanelFrameCategory(props), getVisualizationOptions(props), getLibraryPanelOptionsCategory(props)], // eslint-disable-next-line react-hooks/exhaustive-deps [panel.configRev, props.data, props.instanceState, searchQuery] diff --git a/public/app/features/dashboard/components/PanelEditor/OverrideCategoryTitle.tsx b/public/app/features/dashboard/components/PanelEditor/OverrideCategoryTitle.tsx index 7717707f9d648..3fef902396662 100644 --- a/public/app/features/dashboard/components/PanelEditor/OverrideCategoryTitle.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OverrideCategoryTitle.tsx @@ -29,7 +29,7 @@ export const OverrideCategoryTitle: FC = ({
    {overrideName}
    - {isExpanded && } +
    {!isExpanded && (
    diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx index 7b7e684df5754..85029438a59da 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx @@ -4,6 +4,7 @@ import { PanelModel } from '../../state'; import { getLocationSrv } from '@grafana/runtime'; import { QueryGroupDataSource, QueryGroupOptions } from 'app/types'; import { DataQuery } from '@grafana/data'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; interface Props { /** Current panel */ @@ -18,17 +19,18 @@ export class PanelEditorQueries extends PureComponent { } buildQueryOptions(panel: PanelModel): QueryGroupOptions { - const dataSource: QueryGroupDataSource = panel.datasource?.uid - ? { - default: false, - ...panel.datasource, - } - : { - default: true, - }; + const dataSource: QueryGroupDataSource = panel.datasource ?? { + default: true, + }; + const datasourceSettings = getDatasourceSrv().getInstanceSettings(dataSource); return { - dataSource, + cacheTimeout: datasourceSettings?.meta.queryOptions?.cacheTimeout ? panel.cacheTimeout : undefined, + dataSource: { + default: datasourceSettings?.isDefault, + type: datasourceSettings?.type, + uid: datasourceSettings?.uid, + }, queries: panel.targets, maxDataPoints: panel.maxDataPoints, minInterval: panel.interval, diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx index bc6a30ac454da..1c04cfc247301 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx @@ -40,18 +40,8 @@ export const PanelEditorTabs: FC = React.memo(({ panel, da
    {tabs.map((tab) => { - if (config.unifiedAlertingEnabled && tab.id === PanelEditorTabId.Alert) { - return ( - onChangeTab(tab)} - icon={tab.icon as IconName} - panel={panel} - dashboard={dashboard} - /> - ); + if (tab.id === PanelEditorTabId.Alert) { + renderAlertTab(tab, panel, dashboard, onChangeTab); } return ( void +) { + if (!config.alertingEnabled || !config.unifiedAlertingEnabled) { + return null; + } else if (config.unifiedAlertingEnabled) { + return ( + onChangeTab(tab)} + icon={tab.icon as IconName} + panel={panel} + dashboard={dashboard} + /> + ); + } else if (config.alertingEnabled) { + return ( + onChangeTab(tab)} + icon={tab.icon as IconName} + counter={getCounter(panel, tab)} + /> + ); + } + + return null; +} + const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css` diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index a0b953cb56848..ca2e2e18f40e4 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -14,6 +14,8 @@ import { getPanelPluginWithFallback } from '../../state/selectors'; import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions'; import { useLocalStorage } from 'react-use'; +import { VisualizationSelectPaneTab } from './types'; +import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; interface Props { panel: PanelModel; @@ -23,7 +25,11 @@ interface Props { export const VisualizationSelectPane: FC = ({ panel, data }) => { const plugin = useSelector(getPanelPluginWithFallback(panel.type)); const [searchQuery, setSearchQuery] = useState(''); - const [listMode, setListMode] = useLocalStorage(`VisualizationSelectPane.ListMode`, ListMode.Visualizations); + const [listMode, setListMode] = useLocalStorage( + LS_VISUALIZATION_SELECT_TAB_KEY, + VisualizationSelectPaneTab.Visualizations + ); + const dispatch = useDispatch(); const styles = useStyles(getStyles); const searchRef = useRef(null); @@ -51,31 +57,16 @@ export const VisualizationSelectPane: FC = ({ panel, data }) => { dispatch(toggleVizPicker(false)); }; - // const onKeyPress = useCallback( - // (e: React.KeyboardEvent) => { - // if (e.key === 'Enter') { - // const query = e.currentTarget.value; - // const plugins = getAllPanelPluginMeta(); - // const match = filterPluginList(plugins, query, plugin.meta); - - // if (match && match.length) { - // onPluginTypeChange(match[0], false); - // } - // } - // }, - // [onPluginTypeChange, plugin.meta] - // ); - if (!plugin) { return null; } - const radioOptions: Array> = [ - { label: 'Visualizations', value: ListMode.Visualizations }, - { label: 'Suggestions', value: ListMode.Suggestions }, + const radioOptions: Array> = [ + { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, + { label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, { label: 'Library panels', - value: ListMode.LibraryPanels, + value: VisualizationSelectPaneTab.LibraryPanels, description: 'Reusable panels you can share between multiple dashboards.', }, ]; @@ -107,7 +98,7 @@ export const VisualizationSelectPane: FC = ({ panel, data }) => {
    - {listMode === ListMode.Visualizations && ( + {listMode === VisualizationSelectPaneTab.Visualizations && ( = ({ panel, data }) => { onClose={() => {}} /> )} - {listMode === ListMode.Suggestions && ( + {listMode === VisualizationSelectPaneTab.Suggestions && ( = ({ panel, data }) => { onClose={() => {}} /> )} - {listMode === ListMode.LibraryPanels && ( + {listMode === VisualizationSelectPaneTab.LibraryPanels && ( )}
    @@ -136,12 +127,6 @@ export const VisualizationSelectPane: FC = ({ panel, data }) => { ); }; -enum ListMode { - Visualizations, - LibraryPanels, - Suggestions, -} - VisualizationSelectPane.displayName = 'VisualizationSelectPane'; const getStyles = (theme: GrafanaTheme) => { diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts new file mode 100644 index 0000000000000..3d3c461f6f886 --- /dev/null +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.test.ts @@ -0,0 +1,161 @@ +import { EventBusSrv, FieldType, getDefaultTimeRange, LoadingState, toDataFrame } from '@grafana/data'; +import { getStandardEditorContext } from './getVisualizationOptions'; + +describe('getStandardEditorContext', () => { + it('defaults the series data to an empty array', () => { + const editorContext = getStandardEditorContext({ + data: undefined, + replaceVariables: jest.fn(), + options: {}, + eventBus: new EventBusSrv(), + instanceState: {}, + }); + + expect(editorContext.data).toEqual([]); + }); + + it('returns suggestions for empty data', () => { + const editorContext = getStandardEditorContext({ + data: undefined, + replaceVariables: jest.fn(), + options: {}, + eventBus: new EventBusSrv(), + instanceState: {}, + }); + + expect(editorContext.getSuggestions).toBeDefined(); + expect(editorContext.getSuggestions?.()).toEqual([ + { + documentation: 'Name of the series', + label: 'Name', + origin: 'series', + value: '__series.name', + }, + { + documentation: 'Field name of the clicked datapoint (in ms epoch)', + label: 'Name', + origin: 'field', + value: '__field.name', + }, + { + documentation: 'Adds current variables', + label: 'All variables', + origin: 'template', + value: '__all_variables', + }, + { + documentation: 'Adds current time range', + label: 'Time range', + origin: 'built-in', + value: '__url_time_range', + }, + { + documentation: "Adds current time range's from value", + label: 'Time range: from', + origin: 'built-in', + value: '__from', + }, + { + documentation: "Adds current time range's to value", + label: 'Time range: to', + origin: 'built-in', + value: '__to', + }, + ]); + }); + + it('returns suggestions for non-empty data', () => { + const series = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'score', type: FieldType.number }, + ], + }), + ]; + + const panelData = { + series, + timeRange: getDefaultTimeRange(), + state: LoadingState.Done, + }; + + const editorContext = getStandardEditorContext({ + data: panelData, + replaceVariables: jest.fn(), + options: {}, + eventBus: new EventBusSrv(), + instanceState: {}, + }); + + expect(editorContext.getSuggestions).toBeDefined(); + expect(editorContext.getSuggestions?.()).toEqual([ + { + documentation: 'Name of the series', + label: 'Name', + origin: 'series', + value: '__series.name', + }, + { + documentation: 'Field name of the clicked datapoint (in ms epoch)', + label: 'Name', + origin: 'field', + value: '__field.name', + }, + { + documentation: 'Formatted value for time on the same row', + label: 'time', + origin: 'fields', + value: '__data.fields.time', + }, + { + documentation: 'Formatted value for score on the same row', + label: 'score', + origin: 'fields', + value: '__data.fields.score', + }, + { + documentation: 'Enter the field order', + label: 'Select by index', + origin: 'fields', + value: '__data.fields[0]', + }, + { + documentation: 'the numeric field value', + label: 'Show numeric value', + origin: 'fields', + value: '__data.fields.score.numeric', + }, + { + documentation: 'the text value', + label: 'Show text value', + origin: 'fields', + value: '__data.fields.score.text', + }, + { + documentation: 'Adds current variables', + label: 'All variables', + origin: 'template', + value: '__all_variables', + }, + { + documentation: 'Adds current time range', + label: 'Time range', + origin: 'built-in', + value: '__url_time_range', + }, + { + documentation: "Adds current time range's from value", + label: 'Time range: from', + origin: 'built-in', + value: '__from', + }, + { + documentation: "Adds current time range's to value", + label: 'Time range: to', + origin: 'built-in', + value: '__to', + }, + ]); + }); +}); diff --git a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx similarity index 83% rename from public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx rename to public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx index 2a2a5ba2b584f..be266ae7e9b3a 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx @@ -1,9 +1,15 @@ import React from 'react'; -import { StandardEditorContext, VariableSuggestionsScope } from '@grafana/data'; +import { + EventBus, + InterpolateFunction, + PanelData, + StandardEditorContext, + VariableSuggestionsScope, +} from '@grafana/data'; import { get as lodashGet } from 'lodash'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { OptionPaneRenderProps } from './types'; -import { updateDefaultFieldConfigValue, setOptionImmutably } from './utils'; +import { setOptionImmutably, updateDefaultFieldConfigValue } from './utils'; import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; import { @@ -16,22 +22,48 @@ import { getOptionOverrides } from './state/getOptionOverrides'; type categoryGetter = (categoryNames?: string[]) => OptionsPaneCategoryDescriptor; -export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] { +interface GetStandardEditorContextProps { + data: PanelData | undefined; + replaceVariables: InterpolateFunction; + options: Record; + eventBus: EventBus; + instanceState: OptionPaneRenderProps['instanceState']; +} + +export function getStandardEditorContext({ + data, + replaceVariables, + options, + eventBus, + instanceState, +}: GetStandardEditorContextProps): StandardEditorContext { + const dataSeries = data?.series ?? []; + + const context: StandardEditorContext = { + data: dataSeries, + replaceVariables, + options, + eventBus, + getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(dataSeries, scope), + instanceState, + }; + + return context; +} + +export function getVisualizationOptions(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] { const { plugin, panel, onPanelOptionsChanged, onFieldConfigsChange, data, dashboard, instanceState } = props; const currentOptions = panel.getOptions(); const currentFieldConfig = panel.fieldConfig; const categoryIndex: Record = {}; - const context: StandardEditorContext = { - data: data?.series || [], + const context = getStandardEditorContext({ + data, replaceVariables: panel.replaceVariables, options: currentOptions, eventBus: dashboard.events, - getSuggestions: (scope?: VariableSuggestionsScope) => { - return data ? getDataLinksVariableSuggestions(data.series, scope) : []; - }, instanceState, - }; + }); const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => { const categoryName = (categoryNames && categoryNames[0]) ?? `${plugin.meta.name}`; diff --git a/public/app/features/dashboard/components/PanelEditor/types.ts b/public/app/features/dashboard/components/PanelEditor/types.ts index 7ca735ded5b3a..6e4b2306637f4 100644 --- a/public/app/features/dashboard/components/PanelEditor/types.ts +++ b/public/app/features/dashboard/components/PanelEditor/types.ts @@ -66,3 +66,9 @@ export interface OptionPaneItemOverrideInfo { tooltip: string; description: string; } + +export enum VisualizationSelectPaneTab { + Visualizations, + LibraryPanels, + Suggestions, +} diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx index 511518359c79c..6b36dc3427250 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useState } from 'react'; import { css } from '@emotion/css'; import { saveAs } from 'file-saver'; -import { Button, Modal, stylesFactory, TextArea, useTheme } from '@grafana/ui'; -import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; +import { Button, Modal, stylesFactory, TextArea, useTheme, ClipboardButton } from '@grafana/ui'; import { SaveDashboardFormProps } from '../types'; import { AppEvents, GrafanaTheme } from '@grafana/data'; import appEvents from '../../../../../core/app_events'; @@ -61,9 +60,9 @@ export const SaveProvisionedDashboardForm: React.FC = ({ - dashboardJSON} elType={Button} onSuccess={onCopyToClipboardSuccess}> + dashboardJSON} onClipboardCopy={onCopyToClipboardSuccess}> Copy JSON to clipboard - +
    diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx index 51b24ab346ed2..c46dc442e0138 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Alert, ClipboardButton, Field, FieldSet, Icon, Input, RadioButtonGroup, Switch } from '@grafana/ui'; -import { AppEvents, SelectableValue } from '@grafana/data'; +import { AppEvents, NavModelItem, SelectableValue } from '@grafana/data'; import { buildImageUrl, buildShareUrl } from './utils'; import { appEvents } from 'app/core/core'; import config from 'app/core/config'; @@ -86,6 +86,9 @@ export class ShareLink extends PureComponent { const { useCurrentTimeRange, useShortUrl, selectedTheme, shareUrl, imageUrl } = this.state; const selectors = e2eSelectors.pages.SharePanelModal; const isDashboardSaved = Boolean(dashboard.id); + const isPMM = !!((config.bootData.navTree || []) as NavModelItem[]).find((item) => item.id === 'pmm'); + const differentLocalhostDomains = + isPMM && config.appUrl.includes('localhost') && !window.location.host.includes('localhost'); return ( <> @@ -106,7 +109,19 @@ export class ShareLink extends PureComponent { - + {differentLocalhostDomains && ( + +

    + Your domain on Grafana's .ini file is localhost but you are on a different domain. The short URL + will point to localhost, which might be wrong. +

    +

    + Please change your .ini and restart Grafana if you want the URL shortener to function correctly, or just + use the full URL. +

    +
    + )} + @@ -124,36 +139,28 @@ export class ShareLink extends PureComponent {
    - {panel && config.rendererAvailable && ( - <> - {isDashboardSaved && ( -
    - )} - - {!isDashboardSaved && ( - - To render a panel image, you must save the dashboard first. - - )} - + {panel && config.rendererAvailable && isDashboardSaved && ( + )} {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. + +

    + <>To render a panel image, you must install the + + Image Renderer plugin + + . Please contact your PMM 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 index 984d77ce8bf44..9863e562a470c 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -112,7 +112,7 @@ export class ShareModal extends React.Component { 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 index 9b1221fc19b51..ec6d7e8786b09 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -1,5 +1,5 @@ import React, { PureComponent } from 'react'; -import { Button, ClipboardButton, Field, Icon, Input, LinkButton, Modal, Select, Spinner } from '@grafana/ui'; +import { Button, ClipboardButton, Field, Icon, Input, Modal, Select, Spinner } from '@grafana/ui'; import { AppEvents, SelectableValue } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; @@ -15,6 +15,7 @@ const expireOptions: Array> = [ { label: '1 Hour', value: 60 * 60 }, { label: '1 Day', value: 60 * 60 * 24 }, { label: '7 Days', value: 60 * 60 * 24 * 7 }, + { label: '90 Days', value: 60 * 60 * 24 * 90 }, ]; interface Props extends ShareModalTabProps {} @@ -41,10 +42,10 @@ export class ShareSnapshot extends PureComponent { this.state = { isLoading: false, step: 1, - selectedExpireOption: expireOptions[0], - snapshotExpires: expireOptions[0].value, + selectedExpireOption: expireOptions[4], + snapshotExpires: expireOptions[4].value, snapshotName: props.dashboard.title, - timeoutSeconds: 4, + timeoutSeconds: 30, snapshotUrl: '', deleteUrl: '', externalEnabled: false, @@ -74,11 +75,15 @@ export class ShareSnapshot extends PureComponent { this.dashboard.snapshot.originalUrl = window.location.href; } + // @ts-ignore + window.forceRefresh = true; this.setState({ isLoading: true }); this.dashboard.startRefresh(); setTimeout(() => { this.saveSnapshot(this.dashboard, external); + // @ts-ignore + window.forceRefresh = false; }, timeoutSeconds * 1000); }; @@ -210,13 +215,33 @@ export class ShareSnapshot extends PureComponent { <>

    - 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. + A snapshot is a way to securely share your dashboard with Percona. When created, we{' '} + strip sensitive data + like queries (metrics, template variables, and annotations) along with panel links. The shared dashboard + will only be available for viewing by Percona Engineers, and the content on the dashboard will assist + Percona Engineers in troubleshooting your case.

    - Keep in mind, your snapshot can be viewed by anyone that has the link and can access the URL. Share - wisely. + You can safely leave the defaults set as they are, but for further information: +

    +

    +

      +
    • + Snapshot Name: Give the snapshot a name so that Percona can distinguish your dashboard. +
    • +
    • + Expire: The time before snapshot expires. Configure lower if required. Percona + automatically purge shared dashboards after 90 days. +
    • +
    • + Timeout (seconds): Time the dashboard takes to load before the snapshot is generated. +
    • +
    +

    +

    + What to do next: Once you click Share with Percona, wait for the dashboard to be + generated, and you will be provided with a unique URL that then needs to be communicated to Percona via your + ticket.

    @@ -273,13 +298,6 @@ export class ShareSnapshot extends PureComponent {
    - -
    - Did you make a mistake?{' '} - - Delete snapshot. - -
    ); } diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index 203af35cf6064..6f83df71f8fd6 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -1,5 +1,6 @@ import React, { useRef, useState, useLayoutEffect } from 'react'; import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; +import { config } from '@grafana/runtime'; import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { getLinkSrv } from '../../../panel/panellinks/link_srv'; @@ -8,6 +9,7 @@ import { DashboardSearchHit } from 'app/features/search/types'; import { selectors } from '@grafana/e2e-selectors'; import { useAsync } from 'react-use'; import { css, cx } from '@emotion/css'; +import { isPmmAdmin } from 'app/percona/shared/helpers/permissions'; interface Props { link: DashboardLink; @@ -20,7 +22,7 @@ export const DashboardLinksDashboard: React.FC = (props) => { const listRef = useRef(null); const [dropdownCssClass, setDropdownCssClass] = useState('invisible'); const [opened, setOpened] = useState(0); - const resolvedLinks = useResolvedLinks(props, opened); + let resolvedLinks = useResolvedLinks(props, opened); const buttonStyle = useStyles2( (theme) => @@ -33,6 +35,20 @@ export const DashboardLinksDashboard: React.FC = (props) => { setDropdownCssClass(getDropdownLocationCssClass(listRef.current)); }, [resolvedLinks]); + // TODO: PMM-7736 remove it ASAP after migration transition period is finished + if (link.title === 'PMM') { + if (isPmmAdmin(config.bootData.user)) { + resolvedLinks = [ + { id: 'pmm-add-instance', url: '/graph/add-instance', title: 'PMM Add Instance' }, + { id: 'pmm-database-checks', url: '/graph/pmm-database-checks', title: 'PMM Advisor Checks' }, + { id: 'pmm-inventory', url: '/graph/inventory', title: 'PMM Inventory' }, + { id: 'pmm-settings', url: '/graph/settings', title: 'PMM Settings' }, + ]; + } else { + return <>; + } + } + if (link.asDropdown) { return ( diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx index 2337d7cd75e12..c2d82b330d7e5 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx @@ -9,7 +9,7 @@ import { } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; import { TransformationsEditorTransformation } from './types'; -import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo'; +import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; import { useToggle } from 'react-use'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index 40880a97b1b0f..b88c5f9aa3e48 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -33,7 +33,7 @@ import { TransformationsEditorTransformation } from './types'; import { PanelNotSupported } from '../PanelEditor/PanelNotSupported'; import { AppNotificationSeverity } from '../../../../types'; import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; -import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo'; +import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed'; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 98cd65d0cbc0b..f72d15a3ecc4d 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux'; import { render, screen } from '@testing-library/react'; import { Props, UnthemedDashboardPage } from './DashboardPage'; import { Router } from 'react-router-dom'; -import { locationService } from '@grafana/runtime'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { DashboardModel } from '../state'; import { configureStore } from '../../../store/configureStore'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; @@ -182,7 +182,7 @@ describe('DashboardPage', () => { }); it('Should update title', () => { - expect(document.title).toBe('My dashboard - Grafana'); + expect(document.title).toBe('My dashboard - Percona Monitoring and Management'); }); }); @@ -208,6 +208,12 @@ describe('DashboardPage', () => { }); dashboardPageScenario('When going into edit mode', (ctx) => { + setDataSourceSrv({ + get: jest.fn().mockResolvedValue({}), + getInstanceSettings: jest.fn().mockReturnValue({ meta: {} }), + getList: jest.fn(), + }); + ctx.setup(() => { ctx.mount({ dashboard: getTestDashboard(), diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts index e00e63cc059c3..24a10364e1041 100644 --- a/public/app/features/dashboard/services/TimeSrv.test.ts +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -1,6 +1,7 @@ import { TimeSrv } from './TimeSrv'; import { ContextSrvStub } from 'test/specs/helpers'; import { dateTime, isDateTime } from '@grafana/data'; +import * as H from 'history'; import { HistoryWrapper, locationService, setLocationService } from '@grafana/runtime'; import { beforeEach } from '../../../../test/lib/common'; @@ -13,7 +14,7 @@ jest.mock('app/core/core', () => ({ describe('timeSrv', () => { let timeSrv: TimeSrv; let _dashboard: any; - const pushSpy = jest.fn(); + let locationUpdates: H.Location[] = []; beforeEach(() => { _dashboard = { @@ -22,18 +23,15 @@ describe('timeSrv', () => { refresh: false, timeRangeUpdated: jest.fn(() => {}), }; + timeSrv = new TimeSrv(new ContextSrvStub() as any); timeSrv.init(_dashboard); beforeEach(() => { - pushSpy.mockClear(); - - setLocationService(new HistoryWrapper()); - const origPush = locationService.push; - locationService.push = (args: any) => { - pushSpy(); - origPush(args); - }; + locationUpdates = []; + const history = new HistoryWrapper(); + history.getHistory().listen((x) => locationUpdates.push(x)); + setLocationService(history); }); }); @@ -237,7 +235,16 @@ describe('timeSrv', () => { timeSrv.setTime({ from: 'now-1h', to: 'now-10s' }); timeSrv.setTime({ from: 'now-1h', to: 'now-10s' }); - expect(pushSpy).toHaveBeenCalledTimes(1); + expect(locationUpdates.length).toBe(1); + }); + + it('should update location so that bool params are preserved', () => { + locationService.partial({ kiosk: true }); + + timeSrv.setTime({ from: 'now-1h', to: 'now-10s' }); + timeSrv.setTime({ from: 'now-1h', to: 'now-10s' }); + + expect(locationUpdates[1].search).toEqual('?kiosk&from=now-1h&to=now-10s'); }); }); diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index 68576ec0a45a9..db23af30d3cdf 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -287,22 +287,16 @@ export class TimeSrv { // update url if (fromRouteUpdate !== true) { const urlRange = this.timeRangeForUrl(); - const urlParams = locationService.getSearch(); + const urlParams = locationService.getSearchObject(); - const from = urlParams.get('from'); - const to = urlParams.get('to'); - - if (from && to && from === urlRange.from.toString() && to === urlRange.to.toString()) { + if (urlParams.from === urlRange.from.toString() && urlParams.to === urlRange.to.toString()) { return; } - urlParams.set('from', urlRange.from.toString()); - urlParams.set('to', urlRange.to.toString()); + urlParams.from = urlRange.from.toString(); + urlParams.to = urlRange.to.toString(); - locationService.push({ - ...locationService.getLocation(), - search: urlParams.toString(), - }); + locationService.partial(urlParams); } this.refreshDashboard(); diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index ce67c1410aadb..c9a34b728dc95 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -179,7 +179,7 @@ describe('DashboardModel', () => { }); it('dashboard schema version should be set to latest', () => { - expect(model.schemaVersion).toBe(33); + expect(model.schemaVersion).toBe(34); }); it('graph thresholds should be migrated', () => { @@ -1815,8 +1815,8 @@ describe('DashboardModel', () => { }); }); - it('should update variable datasource props to refs', () => { - expect(model.templating.list[0].datasource).toEqual({ type: 'prometheus', uid: 'mock-ds-2' }); + it('should not update variable datasource props to refs', () => { + expect(model.templating.list[0].datasource).toEqual('prom'); }); it('should update panel datasource props to refs for named data source', () => { diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index 323428eb191b6..16e2f7f003f5c 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -7,24 +7,24 @@ import kbn from 'app/core/utils/kbn'; import { PanelModel } from './PanelModel'; import { DashboardModel } from './DashboardModel'; import { + AnnotationQuery, DataLink, DataLinkBuiltInVars, + DataQuery, DataSourceRef, + DataTransformerConfig, + getActiveThreshold, + getDataSourceRef, + isDataSourceRef, MappingType, - SpecialValueMatch, PanelPlugin, + SpecialValueMatch, standardEditorsRegistry, standardFieldConfigEditorRegistry, ThresholdsConfig, urlUtil, ValueMap, ValueMapping, - getActiveThreshold, - DataTransformerConfig, - AnnotationQuery, - DataQuery, - getDataSourceRef, - isDataSourceRef, } from '@grafana/data'; // Constants import { @@ -46,10 +46,11 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { labelsToFieldsTransformer } from '../../../../../packages/grafana-data/src/transformations/transformers/labelsToFields'; import { mergeTransformer } from '../../../../../packages/grafana-data/src/transformations/transformers/merge'; import { - migrateMultipleStatsMetricsQuery, + migrateCloudWatchQuery, migrateMultipleStatsAnnotationQuery, + migrateMultipleStatsMetricsQuery, } from 'app/plugins/datasource/cloudwatch/migrations'; -import { CloudWatchMetricsQuery, CloudWatchAnnotationQuery } from 'app/plugins/datasource/cloudwatch/types'; +import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery } from 'app/plugins/datasource/cloudwatch/types'; standardEditorsRegistry.setInit(getStandardOptionEditors); standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); @@ -66,7 +67,7 @@ export class DashboardMigrator { let i, j, k, n; const oldVersion = this.dashboard.schemaVersion; const panelUpgrades: PanelSchemeUpgradeHandler[] = []; - this.dashboard.schemaVersion = 33; + this.dashboard.schemaVersion = 34; if (oldVersion === this.dashboard.schemaVersion) { return; @@ -691,23 +692,11 @@ export class DashboardMigrator { } if (oldVersion < 32) { - panelUpgrades.push((panel: PanelModel) => { - this.migrateCloudWatchQueries(panel); - return panel; - }); - - this.migrateCloudWatchAnnotationQuery(); + // CloudWatch migrations have been moved to version 34 } // Replace datasource name with reference, uid and type if (oldVersion < 33) { - for (const variable of this.dashboard.templating.list) { - if (variable.type !== 'query') { - continue; - } - variable.datasource = migrateDatasourceNameToRef(variable.datasource); - } - panelUpgrades.push((panel) => { panel.datasource = migrateDatasourceNameToRef(panel.datasource); @@ -726,6 +715,15 @@ export class DashboardMigrator { }); } + if (oldVersion < 34) { + panelUpgrades.push((panel: PanelModel) => { + this.migrateCloudWatchQueries(panel); + return panel; + }); + + this.migrateCloudWatchAnnotationQuery(); + } + if (panelUpgrades.length === 0) { return; } @@ -747,10 +745,14 @@ export class DashboardMigrator { // New queries, that were created during migration, are put at the end of the array. migrateCloudWatchQueries(panel: PanelModel) { for (const target of panel.targets || []) { - if (isLegacyCloudWatchQuery(target)) { - const newQueries = migrateMultipleStatsMetricsQuery(target, [...panel.targets]); - for (const newQuery of newQueries) { - panel.targets.push(newQuery); + if (isCloudWatchQuery(target)) { + migrateCloudWatchQuery(target); + if (target.hasOwnProperty('statistics')) { + // New queries, that were created during migration, are put at the end of the array. + const newQueries = migrateMultipleStatsMetricsQuery(target, [...panel.targets]); + for (const newQuery of newQueries) { + panel.targets.push(newQuery); + } } } } @@ -1099,12 +1101,12 @@ function upgradeValueMappingsForPanel(panel: PanelModel) { return panel; } -function isLegacyCloudWatchQuery(target: DataQuery): target is CloudWatchMetricsQuery { +function isCloudWatchQuery(target: DataQuery): target is CloudWatchMetricsQuery { return ( target.hasOwnProperty('dimensions') && target.hasOwnProperty('namespace') && target.hasOwnProperty('region') && - target.hasOwnProperty('statistics') + target.hasOwnProperty('metricName') ); } diff --git a/public/app/features/dashboard/state/DashboardModel.refresh.test.ts b/public/app/features/dashboard/state/DashboardModel.refresh.test.ts index a5a76eab2c309..338540becdccd 100644 --- a/public/app/features/dashboard/state/DashboardModel.refresh.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.refresh.test.ts @@ -8,7 +8,8 @@ import { afterEach, beforeEach } from '../../../../test/lib/common'; function getTestContext({ usePanelInEdit, usePanelInView, -}: { usePanelInEdit?: boolean; usePanelInView?: boolean } = {}) { + refreshAll = false, +}: { usePanelInEdit?: boolean; usePanelInView?: boolean; refreshAll?: boolean } = {}) { jest.clearAllMocks(); const dashboard = new DashboardModel({}); @@ -26,7 +27,7 @@ function getTestContext({ panelIds.push(panelInView.id); } - appEvents.publish(new VariablesChanged({ panelIds })); + appEvents.publish(new VariablesChanged({ panelIds, refreshAll })); return { dashboard, startRefreshMock, panelInEdit, panelInView }; } @@ -37,7 +38,16 @@ describe('Strict panel refresh', () => { const { startRefreshMock } = getTestContext(); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [1, 2, 3], refreshAll: false }); + }); + }); + + describe('when there is no panel in full view or panel in panel edit during variable change but refreshAll is true', () => { + it('then all affected panels should be refreshed', () => { + const { startRefreshMock } = getTestContext({ refreshAll: true }); + + expect(startRefreshMock).toHaveBeenCalledTimes(1); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [], refreshAll: true }); }); }); @@ -61,7 +71,7 @@ describe('Strict panel refresh', () => { const { startRefreshMock } = getTestContext(); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith(undefined); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [], refreshAll: true }); }); }); @@ -71,7 +81,17 @@ describe('Strict panel refresh', () => { const { startRefreshMock } = getTestContext(); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [1, 2, 3], refreshAll: false }); + }); + }); + + describe('when the dashboard has been refreshed within the threshold but refreshAll is true', () => { + it('then all affected panels should be refreshed', () => { + isRefreshOutsideThreshold = false; + const { startRefreshMock } = getTestContext({ refreshAll: true }); + + expect(startRefreshMock).toHaveBeenCalledTimes(1); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [], refreshAll: true }); }); }); }); @@ -81,7 +101,7 @@ describe('Strict panel refresh', () => { const { panelInView, startRefreshMock } = getTestContext({ usePanelInView: true }); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3, panelInView.id]); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [1, 2, 3, panelInView.id], refreshAll: false }); }); describe('and when exitViewPanel is called', () => { @@ -92,7 +112,7 @@ describe('Strict panel refresh', () => { dashboard.exitViewPanel(panelInView); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [1, 2, 3], refreshAll: false }); expect(dashboard['panelsAffectedByVariableChange']).toBeNull(); }); }); @@ -102,7 +122,7 @@ describe('Strict panel refresh', () => { it('then all affected panels should be refreshed', () => { const { panelInEdit, startRefreshMock } = getTestContext({ usePanelInEdit: true }); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3, panelInEdit.id]); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [1, 2, 3, panelInEdit.id], refreshAll: false }); }); describe('and when exitViewPanel is called', () => { @@ -113,7 +133,7 @@ describe('Strict panel refresh', () => { dashboard.exitPanelEditor(); expect(startRefreshMock).toHaveBeenCalledTimes(1); - expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]); + expect(startRefreshMock).toHaveBeenLastCalledWith({ panelIds: [1, 2, 3], refreshAll: false }); expect(dashboard['panelsAffectedByVariableChange']).toBeNull(); }); }); diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index e321af4c29442..34d47fb8b09e6 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -464,6 +464,46 @@ describe('DashboardModel', () => { }); }); + describe('When expanding row with panels that do not contain an x and y pos', () => { + 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: { w: 12, h: 2 } }, + { id: 4, type: 'graph', gridPos: { w: 12, h: 2 } }, + ], + }, + { id: 5, type: 'row', gridPos: { x: 0, y: 7, w: 1, h: 1 } }, + ], + }); + dashboard.toggleRow(dashboard.panels[1]); + }); + + it('should correctly set the x and y values for the inner panels', () => { + expect(dashboard.panels[2].gridPos).toMatchObject({ + x: 0, + y: 7, + w: 12, + h: 2, + }); + + expect(dashboard.panels[3].gridPos).toMatchObject({ + x: 0, + y: 7, + w: 12, + h: 2, + }); + }); + }); + describe('Given model with time', () => { let model: DashboardModel; diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 04389a82c7728..1ea50c4131519 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -51,8 +51,9 @@ import { Subscription } from 'rxjs'; import { appEvents } from '../../../core/core'; import { VariablesChanged, + VariablesChangedEvent, VariablesChangedInUrl, - VariablesFinishedProcessingTimeRangeChange, + VariablesTimeRangeProcessDone, } from '../../variables/types'; export interface CloneOptions { @@ -183,10 +184,7 @@ export class DashboardModel { this.lastRefresh = Date.now(); this.appEventsSubscription.add(appEvents.subscribe(VariablesChanged, this.variablesChangedHandler.bind(this))); this.appEventsSubscription.add( - appEvents.subscribe( - VariablesFinishedProcessingTimeRangeChange, - this.variablesFinishedProcessingTimeRangeChangeHandler.bind(this) - ) + appEvents.subscribe(VariablesTimeRangeProcessDone, this.variablesTimeRangeProcessDoneHandler.bind(this)) ); this.appEventsSubscription.add( appEvents.subscribe(VariablesChangedInUrl, this.variablesChangedInUrlHandler.bind(this)) @@ -378,12 +376,12 @@ export class DashboardModel { dispatch(onTimeRangeUpdated(timeRange)); } - startRefresh(affectedPanelIds?: number[]) { + startRefresh(event: VariablesChangedEvent = { refreshAll: true, panelIds: [] }) { this.events.publish(new RefreshEvent()); this.lastRefresh = Date.now(); if (this.panelInEdit) { - if (!affectedPanelIds || affectedPanelIds.includes(this.panelInEdit.id)) { + if (event.refreshAll || event.panelIds.includes(this.panelInEdit.id)) { this.panelInEdit.refresh(); return; } @@ -391,7 +389,7 @@ export class DashboardModel { for (const panel of this.panels) { if (!this.otherPanelInFullscreen(panel)) { - if (!affectedPanelIds || affectedPanelIds.includes(panel.id)) { + if (event.refreshAll || event.panelIds.includes(panel.id)) { panel.refresh(); } } @@ -446,7 +444,7 @@ export class DashboardModel { return; } - this.startRefresh(this.panelsAffectedByVariableChange); + this.startRefresh({ panelIds: this.panelsAffectedByVariableChange, refreshAll: false }); this.panelsAffectedByVariableChange = null; } @@ -952,8 +950,10 @@ export class DashboardModel { 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); + // If the panel doesn't have gridPos.y, use the row gridPos.y instead. + // This can happen for some generated dashboards. + const firstPanelYPos = row.panels[0].gridPos.y ?? row.gridPos.y; + const yDiff = firstPanelYPos - (row.gridPos.y + row.gridPos.h); // start inserting after row let insertPos = rowIndex + 1; @@ -962,8 +962,9 @@ export class DashboardModel { let yMax = row.gridPos.y; for (const panel of row.panels) { + // set the y gridPos if it wasn't already set + panel.gridPos.y ??= row.gridPos.y; // 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)); @@ -1217,25 +1218,18 @@ export class DashboardModel { }); } - private variablesChangedHandler(event: VariablesChanged) { - this.variablesChangedBaseHandler(event, true); + private variablesTimeRangeProcessDoneHandler(event: VariablesTimeRangeProcessDone) { + const processRepeats = event.payload.variableIds.length > 0; + this.variablesChangedHandler(new VariablesChanged({ panelIds: [], refreshAll: true }), processRepeats); } - private variablesFinishedProcessingTimeRangeChangeHandler(event: VariablesFinishedProcessingTimeRangeChange) { - this.variablesChangedBaseHandler(event); - } - - private variablesChangedBaseHandler( - event: VariablesChanged | VariablesFinishedProcessingTimeRangeChange, - processRepeats = false - ) { + private variablesChangedHandler(event: VariablesChanged, processRepeats = true) { if (processRepeats) { this.processRepeats(); } - if (!event.payload.panelIds || getTimeSrv().isRefreshOutsideThreshold(this.lastRefresh)) { - // passing undefined in panelIds means we want to update all panels - this.startRefresh(undefined); + if (event.payload.refreshAll || getTimeSrv().isRefreshOutsideThreshold(this.lastRefresh)) { + this.startRefresh({ refreshAll: true, panelIds: [] }); return; } @@ -1245,11 +1239,11 @@ export class DashboardModel { ); } - this.startRefresh(event.payload.panelIds); + this.startRefresh(event.payload); } private variablesChangedInUrlHandler(event: VariablesChangedInUrl) { this.templateVariableValueUpdated(); - this.startRefresh(event.payload.panelIds); + this.startRefresh(event.payload); } } diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index a23607a96a609..7bfc7c72a7d54 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -171,7 +171,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { isInView = false; configRev = 0; // increments when configs change hasRefreshed?: boolean; - cacheTimeout?: any; + cacheTimeout?: string | null; cachedPluginOptions: Record = {}; legend?: { show: boolean; sort?: string; sortDesc?: boolean }; plugin?: PanelPlugin; @@ -313,7 +313,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { timezone: dashboardTimezone, timeRange: timeData.timeRange, timeInfo: timeData.timeInfo, - maxDataPoints: this.maxDataPoints || width, + maxDataPoints: this.maxDataPoints || Math.floor(width), minInterval: this.interval, scopedVars: this.scopedVars, cacheTimeout: this.cacheTimeout, @@ -449,6 +449,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { uid: dataSource.uid, type: dataSource.type, }; + this.cacheTimeout = options.cacheTimeout; this.timeFrom = options.timeRange?.from; this.timeShift = options.timeRange?.shift; this.hideTimeOverride = options.timeRange?.hide; diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index e166fd3778537..5c3c6021252ca 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -2,7 +2,7 @@ import { getBackendSrv } from '@grafana/runtime'; import { createSuccessNotification } from 'app/core/copy/appNotification'; // Actions -import { loadPluginDashboards } from '../../plugins/state/actions'; +import { loadPluginDashboards } from '../../plugins/admin/state/actions'; import { cleanUpDashboard, loadDashboardPermissions } from './reducers'; import { notifyApp } from 'app/core/actions'; import { updateTimeZoneForSession, updateWeekStartForSession } from 'app/features/profile/state/reducers'; diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx index 738b663929e6a..425402ba3b294 100644 --- a/public/app/features/datasources/DataSourceDashboards.tsx +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -9,7 +9,7 @@ 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 { loadPluginDashboards } from '../plugins/admin/state/actions'; import { importDashboard, removeDashboard } from '../dashboard/state/actions'; import { getDataSource } from './state/selectors'; diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index 2b4e699430cb0..f07336c124b34 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -10,7 +10,7 @@ import { addDataSource, loadDataSourcePlugins } from './state/actions'; import { getDataSourcePlugins } from './state/selectors'; import { setDataSourceTypeSearchQuery } from './state/reducers'; import { Card } from 'app/core/components/Card/Card'; -import { PluginsErrorsInfo } from '../plugins/PluginsErrorsInfo'; +import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo'; function mapStateToProps(state: StoreState) { return { diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 14aaed499030f..134a09f7598fc 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -24,7 +24,7 @@ import { StoreState, AccessControlAction } from 'app/types/'; import { DataSourceSettings, urlUtil } from '@grafana/data'; import { Alert, Button } from '@grafana/ui'; import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from '../state/navModel'; -import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo'; +import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; import { selectors } from '@grafana/e2e-selectors'; import { CloudInfoBox } from './CloudInfoBox'; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 3899e244d0a89..4f0580641967c 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -5,7 +5,7 @@ import { updateNavIndex } from 'app/core/actions'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader'; -import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache'; +import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { DataSourcePluginCategory, ThunkDispatch, ThunkResult } from 'app/types'; import config from '../../../core/config'; diff --git a/public/app/features/dimensions/editors/NumberInput.tsx b/public/app/features/dimensions/editors/NumberInput.tsx index 674543e0cf215..da21132a77680 100644 --- a/public/app/features/dimensions/editors/NumberInput.tsx +++ b/public/app/features/dimensions/editors/NumberInput.tsx @@ -1,5 +1,5 @@ import React, { PureComponent } from 'react'; -import { Input } from '@grafana/ui'; +import { Field, Input } from '@grafana/ui'; interface Props { value?: number; @@ -13,16 +13,18 @@ interface Props { interface State { text: string; + inputCorrected: boolean; } /** * This is an Input field that will call `onChange` for blur and enter */ export class NumberInput extends PureComponent { - state: State = { text: '' }; + state: State = { text: '', inputCorrected: false }; componentDidMount() { this.setState({ + ...this.state, text: isNaN(this.props.value!) ? '' : `${this.props.value}`, }); } @@ -30,6 +32,7 @@ export class NumberInput extends PureComponent { componentDidUpdate(oldProps: Props) { if (this.props.value !== oldProps.value) { this.setState({ + ...this.state, text: isNaN(this.props.value!) ? '' : `${this.props.value}`, }); } @@ -42,11 +45,30 @@ export class NumberInput extends PureComponent { value = e.currentTarget.valueAsNumber; } this.props.onChange(value); + this.setState({ ...this.state, inputCorrected: false }); }; onChange = (e: React.FocusEvent) => { + let newValue: string | undefined = undefined; + let corrected = false; + const min = this.props.min; + const max = this.props.max; + const currValue = e.currentTarget.valueAsNumber; + if (!Number.isNaN(currValue)) { + if (min != null && currValue < min) { + newValue = min.toString(); + corrected = true; + } else if (max != null && currValue > max) { + newValue = max.toString(); + corrected = true; + } else { + newValue = e.currentTarget.value; + } + } this.setState({ - text: e.currentTarget.value, + ...this.state, + text: newValue ? newValue : '', + inputCorrected: corrected, }); }; @@ -58,20 +80,22 @@ export class NumberInput extends PureComponent { render() { const { placeholder } = this.props; - const { text } = this.state; + const { text, inputCorrected } = this.state; return ( - + + + ); } } diff --git a/public/app/features/dimensions/editors/ScalarDimensionEditor.tsx b/public/app/features/dimensions/editors/ScalarDimensionEditor.tsx new file mode 100644 index 0000000000000..4efef4cb0ec0a --- /dev/null +++ b/public/app/features/dimensions/editors/ScalarDimensionEditor.tsx @@ -0,0 +1,116 @@ +import React, { FC, useCallback } from 'react'; +import { FieldType, GrafanaTheme2, SelectableValue, StandardEditorProps } from '@grafana/data'; +import { ScalarDimensionConfig, ScalarDimensionMode, ScalarDimensionOptions } from '../types'; +import { InlineField, InlineFieldRow, RadioButtonGroup, Select, useStyles2 } from '@grafana/ui'; +import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/src/components/MatchersUI/utils'; +import { NumberInput } from './NumberInput'; +import { css } from '@emotion/css'; + +const fixedValueOption: SelectableValue = { + label: 'Fixed value', + value: '_____fixed_____', +}; + +const scalarOptions = [ + { label: 'Mod', value: ScalarDimensionMode.Mod, description: 'Use field values, mod from max' }, + { label: 'Clamped', value: ScalarDimensionMode.Clamped, description: 'Use field values, clamped to max and min' }, +]; + +export const ScalarDimensionEditor: FC> = ( + props +) => { + const { value, context, onChange, item } = props; + const { settings } = item; + + const DEFAULT_VALUE = 0; + + const fieldName = value?.field; + const isFixed = Boolean(!fieldName); + const names = useFieldDisplayNames(context.data); + const selectOptions = useSelectOptions(names, fieldName, fixedValueOption, FieldType.number); + + const styles = useStyles2(getStyles); + + const onSelectChange = useCallback( + (selection: SelectableValue) => { + const field = selection.value; + if (field && field !== fixedValueOption.value) { + onChange({ + ...value, + field, + }); + } else { + const fixed = value.fixed ?? DEFAULT_VALUE; + onChange({ + ...value, + field: undefined, + fixed, + }); + } + }, + [onChange, value] + ); + + const onModeChange = useCallback( + (mode) => { + onChange({ + ...value, + mode, + }); + }, + [onChange, value] + ); + + const onValueChange = useCallback( + (v: number | undefined) => { + onChange({ + ...value, + field: undefined, + fixed: v ?? DEFAULT_VALUE, + }); + }, + [onChange, value] + ); + + const val = value ?? {}; + const mode = value?.mode ?? ScalarDimensionMode.Mod; + const selectedOption = isFixed ? fixedValueOption : selectOptions.find((v) => v.value === fieldName); + return ( + <> +
    + + + + + +
    {key.name} {key.role}{formatDate(key.expiration, timeZone)} + {formatDate(key.expiration, timeZone)} + {isExpired && ( + + + + + + )} + onDelete(key)} />
    { table = screen.getByText('', { selector: 'div[data-test-id="KeyValueTable"]' }); expect(table.innerHTML).toContain('client-uuid-3'); }); + + it('resets detail view for new trace with the identical spanID', () => { + const store = configureStore(); + const { rerender } = render( + + {}} /> + + ); + const span = screen.getAllByText('', { selector: 'div[data-test-id="span-view"]' })[2]; + userEvent.click(span); + //Process is in detail view + expect(screen.getByText(/Process/)).toBeInTheDocument(); + + rerender( + + {}} /> + + ); + expect(screen.queryByText(/Process/)).not.toBeInTheDocument(); + }); }); const response: TraceData & { spans: TraceSpanData[] } = { diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index e3c61620d3c3e..f4f105ed3de04 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -36,9 +36,13 @@ type Props = { dataFrames: DataFrame[]; splitOpenFn: SplitOpen; exploreId: ExploreId; + scrollElement?: Element; }; export function TraceView(props: Props) { + // At this point we only show single trace + const frame = props.dataFrames[0]; + const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState(); const { detailStates, @@ -50,7 +54,8 @@ export function TraceView(props: Props) { detailTagsToggle, detailWarningsToggle, detailStackTracesToggle, - } = useDetailState(); + } = useDetailState(frame); + const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide(); const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange(); @@ -63,8 +68,6 @@ export function TraceView(props: Props) { */ const [slim, setSlim] = useState(false); - // At this point we only show single trace. - const frame = props.dataFrames[0]; const traceProp = useMemo(() => transformDataFrames(frame), [frame]); const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans); const dataSourceName = useSelector((state: StoreState) => state.explore[props.exploreId]?.datasourceInstance?.name); @@ -103,7 +106,6 @@ export function TraceView(props: Props) { () => createSpanLinkFactory({ splitOpenFn: props.splitOpenFn, traceToLogsOptions, dataFrame: frame }), [props.splitOpenFn, traceToLogsOptions, frame] ); - const scrollElement = document.getElementsByClassName('scrollbar-view')[0]; const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]); if (!props.dataFrames?.length || !traceProp) { @@ -168,7 +170,7 @@ export function TraceView(props: Props) { linksGetter={noop as any} uiFind={search} createSpanLink={createSpanLink} - scrollElement={scrollElement} + scrollElement={props.scrollElement} /> diff --git a/public/app/features/explore/TraceView/TraceViewContainer.tsx b/public/app/features/explore/TraceView/TraceViewContainer.tsx index c9f60b39f21ff..740d8d4374aa3 100644 --- a/public/app/features/explore/TraceView/TraceViewContainer.tsx +++ b/public/app/features/explore/TraceView/TraceViewContainer.tsx @@ -8,13 +8,19 @@ interface Props { dataFrames: DataFrame[]; splitOpenFn: SplitOpen; exploreId: ExploreId; + scrollElement?: Element; } export function TraceViewContainer(props: Props) { - const { dataFrames, splitOpenFn, exploreId } = props; + const { dataFrames, splitOpenFn, exploreId, scrollElement } = props; return ( - + ); } diff --git a/public/app/features/explore/TraceView/useDetailState.test.ts b/public/app/features/explore/TraceView/useDetailState.test.ts index 012c50ec5c101..6433dbd5f0d1e 100644 --- a/public/app/features/explore/TraceView/useDetailState.test.ts +++ b/public/app/features/explore/TraceView/useDetailState.test.ts @@ -1,10 +1,17 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { useDetailState } from './useDetailState'; import { TraceLog } from '@jaegertracing/jaeger-ui-components/src/types/trace'; +import { DataFrame } from '@grafana/data'; + +const sampleFrame: DataFrame = { + name: 'trace', + fields: [], + length: 0, +}; describe('useDetailState', () => { it('toggles detail', async () => { - const { result } = renderHook(() => useDetailState()); + const { result } = renderHook(() => useDetailState(sampleFrame)); expect(result.current.detailStates.size).toBe(0); act(() => result.current.toggleDetail('span1')); @@ -16,7 +23,7 @@ describe('useDetailState', () => { }); it('toggles logs and logs items', async () => { - const { result } = renderHook(() => useDetailState()); + const { result } = renderHook(() => useDetailState(sampleFrame)); act(() => result.current.toggleDetail('span1')); act(() => result.current.detailLogsToggle('span1')); expect(result.current.detailStates.get('span1')?.logs.isOpen).toBe(true); @@ -27,28 +34,28 @@ describe('useDetailState', () => { }); it('toggles warnings', async () => { - const { result } = renderHook(() => useDetailState()); + const { result } = renderHook(() => useDetailState(sampleFrame)); act(() => result.current.toggleDetail('span1')); act(() => result.current.detailWarningsToggle('span1')); expect(result.current.detailStates.get('span1')?.isWarningsOpen).toBe(true); }); it('toggles references', async () => { - const { result } = renderHook(() => useDetailState()); + const { result } = renderHook(() => useDetailState(sampleFrame)); act(() => result.current.toggleDetail('span1')); act(() => result.current.detailReferencesToggle('span1')); expect(result.current.detailStates.get('span1')?.isReferencesOpen).toBe(true); }); it('toggles processes', async () => { - const { result } = renderHook(() => useDetailState()); + const { result } = renderHook(() => useDetailState(sampleFrame)); act(() => result.current.toggleDetail('span1')); act(() => result.current.detailProcessToggle('span1')); expect(result.current.detailStates.get('span1')?.isProcessOpen).toBe(true); }); it('toggles tags', async () => { - const { result } = renderHook(() => useDetailState()); + const { result } = renderHook(() => useDetailState(sampleFrame)); act(() => result.current.toggleDetail('span1')); act(() => result.current.detailTagsToggle('span1')); expect(result.current.detailStates.get('span1')?.isTagsOpen).toBe(true); diff --git a/public/app/features/explore/TraceView/useDetailState.ts b/public/app/features/explore/TraceView/useDetailState.ts index 2d60a8ca78d2e..802ec4a74c353 100644 --- a/public/app/features/explore/TraceView/useDetailState.ts +++ b/public/app/features/explore/TraceView/useDetailState.ts @@ -1,4 +1,5 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, useEffect } from 'react'; +import { DataFrame } from '@grafana/data'; import { DetailState } from '@jaegertracing/jaeger-ui-components'; import { TraceLog } from '@jaegertracing/jaeger-ui-components/src/types/trace'; @@ -6,9 +7,14 @@ import { TraceLog } from '@jaegertracing/jaeger-ui-components/src/types/trace'; * Keeps state of the span detail. This means whether span details are open but also state of each detail subitem * like logs or tags. */ -export function useDetailState() { +export function useDetailState(frame: DataFrame) { const [detailStates, setDetailStates] = useState(new Map()); + // Clear detail state when new trace arrives + useEffect(() => { + setDetailStates(new Map()); + }, [frame, setDetailStates]); + const toggleDetail = useCallback( function toggleDetail(spanID: string) { const newDetailStates = new Map(detailStates); diff --git a/public/app/features/explore/__snapshots__/Explore.test.tsx.snap b/public/app/features/explore/__snapshots__/Explore.test.tsx.snap index 193e62eaaa010..a27d07eef1e27 100644 --- a/public/app/features/explore/__snapshots__/Explore.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Explore.test.tsx.snap @@ -3,6 +3,7 @@ exports[`Explore should render component 1`] = ` - - -`; diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 58795956e64d3..f228a80ff7ffb 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -3,12 +3,14 @@ import { addResultsToCache, cancelQueries, cancelQueriesAction, + cleanLogsVolumeAction, clearCache, importQueries, queryReducer, runQueries, scanStartAction, scanStopAction, + storeLogsVolumeDataProviderAction, } from './query'; import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; import { interval, Observable, of } from 'rxjs'; @@ -127,7 +129,12 @@ describe('running queries', () => { .givenThunk(cancelQueries) .whenThunkIsDispatched(exploreId); - expect(dispatchedActions).toEqual([scanStopAction({ exploreId }), cancelQueriesAction({ exploreId })]); + expect(dispatchedActions).toEqual([ + scanStopAction({ exploreId }), + cancelQueriesAction({ exploreId }), + storeLogsVolumeDataProviderAction({ exploreId, logsVolumeDataProvider: undefined }), + cleanLogsVolumeAction({ exploreId }), + ]); }); }); @@ -309,18 +316,24 @@ describe('reducer', () => { }); }); - describe('logs volume', () => { + describe('log volume', () => { let dispatch: ThunkDispatch, getState: () => StoreState, unsubscribes: Function[], mockLogsVolumeDataProvider: () => Observable; beforeEach(() => { + unsubscribes = []; mockLogsVolumeDataProvider = () => { - return of( - { state: LoadingState.Loading, error: undefined, data: [] }, - { state: LoadingState.Done, error: undefined, data: [{}] } - ); + return ({ + subscribe: () => { + const unsubscribe = jest.fn(); + unsubscribes.push(unsubscribe); + return { + unsubscribe, + }; + }, + } as unknown) as Observable; }; const store: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({ @@ -346,22 +359,9 @@ describe('reducer', () => { getState = store.getState; setupQueryResponse(getState()); - unsubscribes = []; - - mockLogsVolumeDataProvider = () => { - return ({ - subscribe: () => { - const unsubscribe = jest.fn(); - unsubscribes.push(unsubscribe); - return { - unsubscribe, - }; - }, - } as unknown) as Observable; - }; }); - it('should cancel any unfinished logs volume queries', async () => { + it('should cancel any unfinished logs volume queries when a new query is run', async () => { await dispatch(runQueries(ExploreId.left)); // first query is run automatically // loading in progress - one subscription created, not cleaned up yet @@ -377,9 +377,56 @@ describe('reducer', () => { expect(unsubscribes[1]).not.toBeCalled(); }); + it('should cancel log volume query when the main query is canceled', async () => { + await dispatch(runQueries(ExploreId.left)); + expect(unsubscribes).toHaveLength(1); + expect(unsubscribes[0]).not.toBeCalled(); + + await dispatch(cancelQueries(ExploreId.left)); + expect(unsubscribes).toHaveLength(1); + expect(unsubscribes[0]).toBeCalled(); + + expect(getState().explore[ExploreId.left].logsVolumeData).toBeUndefined(); + expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeUndefined(); + }); + it('should load logs volume after running the query', async () => { await dispatch(runQueries(ExploreId.left)); expect(unsubscribes).toHaveLength(1); }); + + it('should clean any incomplete log volume data when main query is canceled', async () => { + mockLogsVolumeDataProvider = () => { + return of({ state: LoadingState.Loading, error: undefined, data: [] }); + }; + await dispatch(runQueries(ExploreId.left)); + + expect(getState().explore[ExploreId.left].logsVolumeData).toBeDefined(); + expect(getState().explore[ExploreId.left].logsVolumeData!.state).toBe(LoadingState.Loading); + expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeDefined(); + + await dispatch(cancelQueries(ExploreId.left)); + expect(getState().explore[ExploreId.left].logsVolumeData).toBeUndefined(); + expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeUndefined(); + }); + + it('keeps complete log volume data when main query is canceled', async () => { + mockLogsVolumeDataProvider = () => { + return of( + { state: LoadingState.Loading, error: undefined, data: [] }, + { state: LoadingState.Done, error: undefined, data: [{}] } + ); + }; + await dispatch(runQueries(ExploreId.left)); + + expect(getState().explore[ExploreId.left].logsVolumeData).toBeDefined(); + expect(getState().explore[ExploreId.left].logsVolumeData!.state).toBe(LoadingState.Done); + expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeDefined(); + + await dispatch(cancelQueries(ExploreId.left)); + expect(getState().explore[ExploreId.left].logsVolumeData).toBeDefined(); + expect(getState().explore[ExploreId.left].logsVolumeData!.state).toBe(LoadingState.Done); + expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeUndefined(); + }); }); }); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 8467a0be6c5ec..c174258841980 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -110,11 +110,11 @@ export interface StoreLogsVolumeDataProvider { /** * Stores available logs volume provider after running the query. Used internally by runQueries(). */ -const storeLogsVolumeDataProviderAction = createAction( +export const storeLogsVolumeDataProviderAction = createAction( 'explore/storeLogsVolumeDataProviderAction' ); -const cleanLogsVolumeAction = createAction<{ exploreId: ExploreId }>('explore/cleanLogsVolumeAction'); +export const cleanLogsVolumeAction = createAction<{ exploreId: ExploreId }>('explore/cleanLogsVolumeAction'); export interface StoreLogsVolumeDataSubscriptionPayload { exploreId: ExploreId; @@ -221,9 +221,19 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { - return (dispatch) => { + return (dispatch, getState) => { dispatch(scanStopAction({ exploreId })); dispatch(cancelQueriesAction({ exploreId })); + dispatch( + storeLogsVolumeDataProviderAction({ + exploreId, + logsVolumeDataProvider: undefined, + }) + ); + // clear any incomplete data + if (getState().explore[exploreId]!.logsVolumeData?.state !== LoadingState.Done) { + dispatch(cleanLogsVolumeAction({ exploreId })); + } dispatch(stateSave()); }; } @@ -381,7 +391,7 @@ export const runQueries = ( const timeZone = getTimeZone(getState().user); const transaction = buildQueryTransaction(exploreId, queries, queryOptions, range, scanning, timeZone); - let firstResponse = true; + let querySaved = false; dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading })); newQuerySub = runRequest(datasourceInstance, transaction.request) @@ -403,7 +413,7 @@ export const runQueries = ( ) .subscribe( (data) => { - if (!data.error && firstResponse) { + if (data.state !== LoadingState.Loading && !data.error && !querySaved) { // Side-effect: Saving history in localstorage const nextHistory = updateHistory(history, datasourceId, queries); const { richHistory: nextRichHistory, localStorageFull, limitExceeded } = addToRichHistory( @@ -428,10 +438,9 @@ export const runQueries = ( // We save queries to the URL here so that only successfully run queries change the URL. dispatch(stateSave({ replace: options?.replaceUrl })); + querySaved = true; } - firstResponse = false; - dispatch(queryStreamUpdatedAction({ exploreId, response: data })); // Keep scanning for results if this was the last scanning transaction diff --git a/public/app/features/expressions/ExpressionDatasource.ts b/public/app/features/expressions/ExpressionDatasource.ts index 2349eec0dc912..cfc5be8e385de 100644 --- a/public/app/features/expressions/ExpressionDatasource.ts +++ b/public/app/features/expressions/ExpressionDatasource.ts @@ -36,7 +36,7 @@ export const instanceSettings: DataSourceInstanceSettings = { id: -100, uid: ExpressionDatasourceUID, name: ExpressionDatasourceRef.type, - type: 'grafana-expression', + type: ExpressionDatasourceRef.type, access: 'proxy', meta: { baseUrl: '', diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index 26d32b46bb7c3..5bdc51b22362d 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -30,8 +30,8 @@ export function saveFolder(folder: FolderState): ThunkResult { } export function deleteFolder(uid: string): ThunkResult { - return async (dispatch) => { - await backendSrv.delete(`/api/folders/${uid}?forceDeleteRules=true`); + return async () => { + await backendSrv.delete(`/api/folders/${uid}?forceDeleteRules=false`); locationService.push('/dashboards'); }; } diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index 0dbf2c8b5665e..624955c6226a0 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -12,6 +12,7 @@ import { SelectableValue, toCSV, transformDataFrame, + TimeZone, } from '@grafana/data'; import { Button, Container, Spinner, Table } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; @@ -30,6 +31,7 @@ import { transformToOTLP } from 'app/plugins/datasource/tempo/resultTransformer' interface Props { isLoading: boolean; options: GetDataOptions; + timeZone: TimeZone; data?: DataFrame[]; panel?: PanelModel; onOptionsChange?: (options: GetDataOptions) => void; @@ -184,7 +186,7 @@ export class InspectDataTab extends PureComponent { }; getProcessedData(): DataFrame[] { - const { options, panel } = this.props; + const { options, panel, timeZone } = this.props; const data = this.state.transformedData; if (!options.withFieldConfig || !panel) { @@ -197,6 +199,7 @@ export class InspectDataTab extends PureComponent { data, theme: config.theme2, fieldConfig: panel.fieldConfig, + timeZone, replaceVariables: (value: string) => { return value; }, diff --git a/public/app/features/inspector/QueryInspector.tsx b/public/app/features/inspector/QueryInspector.tsx index 226eb1e694193..1433bfe73efba 100644 --- a/public/app/features/inspector/QueryInspector.tsx +++ b/public/app/features/inspector/QueryInspector.tsx @@ -1,10 +1,9 @@ import React, { PureComponent } from 'react'; -import { Button, JSONFormatter, LoadingPlaceholder } from '@grafana/ui'; +import { Button, JSONFormatter, LoadingPlaceholder, ClipboardButton } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { AppEvents, DataFrame } from '@grafana/data'; import appEvents from 'app/core/app_events'; -import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; import { PanelModel } from 'app/features/dashboard/state'; import { getPanelInspectorStyles } from './styles'; import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils'; @@ -294,16 +293,15 @@ export class QueryInspector extends PureComponent { )} {haveData && ( - - - + Copy to clipboard + )}
    diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index 36dacfe1f3a28..332b8ff1c13cd 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -11,6 +11,7 @@ import { Legend, } from '@grafana/ui'; import { DataSourcePicker } from '@grafana/runtime'; +import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { selectors } from '@grafana/e2e-selectors'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; @@ -107,6 +108,9 @@ export const ImportDashboardForm: FC = ({ {inputs.dataSources && inputs.dataSources.map((input: DataSourceInput, index: number) => { + if (input.pluginId === ExpressionDatasourceRef.type) { + return null; + } const dataSourceOption = `dataSources[${index}]`; const current = watchDataSources ?? []; return ( @@ -124,7 +128,7 @@ export const ImportDashboardForm: FC = ({ noDefault={true} placeholder={input.info} pluginId={input.pluginId} - current={current[index]?.name} + current={current[index]?.uid} /> )} control={control} diff --git a/public/app/features/manage-dashboards/state/actions.test.ts b/public/app/features/manage-dashboards/state/actions.test.ts new file mode 100644 index 0000000000000..7c55fb72f2046 --- /dev/null +++ b/public/app/features/manage-dashboards/state/actions.test.ts @@ -0,0 +1,75 @@ +import { setBackendSrv } from '@grafana/runtime'; +import { thunkTester } from 'test/core/thunk/thunkTester'; +import { importDashboard } from './actions'; +import { DataSourceInput, ImportDashboardDTO, initialImportDashboardState, InputType } from './reducers'; + +describe('importDashboard', () => { + it('Should send data source uid', async () => { + const form: ImportDashboardDTO = { + title: 'Asda', + uid: '12', + gnetId: 'asd', + constants: [], + dataSources: [ + { + id: 1, + uid: 'ds-uid', + name: 'ds-name', + type: 'prometheus', + } as any, + ], + elements: [], + folder: { + id: 1, + title: 'title', + }, + }; + + let postArgs: any; + + setBackendSrv({ + post: (url: string, args: any) => { + postArgs = args; + return Promise.resolve({ + importedUrl: '/my/dashboard', + }); + }, + } as any); + + await thunkTester({ + importDashboard: { + ...initialImportDashboardState, + inputs: { + dataSources: [ + { + name: 'ds-name', + pluginId: 'prometheus', + type: InputType.DataSource, + }, + ] as DataSourceInput[], + constants: [], + libraryPanels: [], + }, + }, + }) + .givenThunk(importDashboard) + .whenThunkIsDispatched(form); + + expect(postArgs).toEqual({ + dashboard: { + title: 'Asda', + uid: '12', + }, + folderId: 1, + inputs: [ + { + name: 'ds-name', + pluginId: 'prometheus', + type: 'datasource', + value: 'ds-uid', + }, + ], + overwrite: true, + }); + }); +}); diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 7c1f977906d2a..fecd628bcd59f 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -1,5 +1,4 @@ import { AppEvents, DataSourceInstanceSettings, locationUtil } from '@grafana/data'; -import { getBackendSrv } from 'app/core/services/backend_srv'; import { clearDashboard, fetchDashboard, @@ -16,7 +15,7 @@ import { import { DashboardDataDTO, DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types'; import { appEvents } from '../../../core/core'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; -import { getDataSourceSrv, locationService } from '@grafana/runtime'; +import { getDataSourceSrv, locationService, getBackendSrv } from '@grafana/runtime'; import { DashboardSearchHit } from '../../search/types'; import { getLibraryPanel } from '../../library-panels/state/api'; import { LibraryElementDTO, LibraryElementKind } from '../../library-panels/types'; @@ -139,7 +138,7 @@ export function importDashboard(importDashboardForm: ImportDashboardDTO): ThunkR name: input.name, type: input.type, pluginId: input.pluginId, - value: dataSource.name, + value: dataSource.uid, }); }); @@ -195,7 +194,7 @@ export function moveDashboards(dashboardUids: string[], toFolder: FolderInfo) { } async function moveDashboard(uid: string, toFolder: FolderInfo) { - const fullDash: DashboardDTO = await getBackendSrv().getDashboardByUid(uid); + const fullDash: DashboardDTO = await getBackendSrv().get(`/api/dashboards/uid/${uid}`); if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) { return { alreadyInFolder: true }; @@ -277,8 +276,8 @@ export function saveDashboard(options: SaveDashboardOptions) { function deleteFolder(uid: string, showSuccessAlert: boolean) { return getBackendSrv().request({ method: 'DELETE', - url: `/api/folders/${uid}?forceDeleteRules=true`, - showSuccessAlert: showSuccessAlert === true, + url: `/api/folders/${uid}?forceDeleteRules=false`, + showSuccessAlert: showSuccessAlert, }); } @@ -287,7 +286,7 @@ export function createFolder(payload: any) { } export function searchFolders(query: any, permission?: PermissionLevelString): Promise { - return getBackendSrv().search({ query, type: 'dash-folder', permission }); + return getBackendSrv().get('/api/search', { query, type: 'dash-folder', permission }); } export function getFolderById(id: number): Promise<{ id: number; title: string }> { @@ -298,7 +297,7 @@ export function deleteDashboard(uid: string, showSuccessAlert: boolean) { return getBackendSrv().request({ method: 'DELETE', url: `/api/dashboards/uid/${uid}`, - showSuccessAlert: showSuccessAlert === true, + showSuccessAlert: showSuccessAlert, }); } diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx index 7b62b1f83a6ae..93cca7cc9029d 100644 --- a/public/app/features/org/OrgDetailsPage.test.tsx +++ b/public/app/features/org/OrgDetailsPage.test.tsx @@ -7,6 +7,14 @@ import { Organization } from '../../types'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { setOrganizationName } from './state/reducers'; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const setup = (propOverrides?: object) => { const props: Props = { organization: {} as Organization, diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx index 152969c0be89b..75776303601fb 100644 --- a/public/app/features/org/OrgDetailsPage.tsx +++ b/public/app/features/org/OrgDetailsPage.tsx @@ -6,10 +6,11 @@ import Page from 'app/core/components/Page/Page'; import OrgProfile from './OrgProfile'; import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences'; import { loadOrganization, updateOrganization } from './state/actions'; -import { Organization, StoreState } from 'app/types'; +import { AccessControlAction, Organization, StoreState } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { setOrganizationName } from './state/reducers'; import { VerticalGroup } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; export interface Props { navModel: NavModel; @@ -32,14 +33,17 @@ export class OrgDetailsPage extends PureComponent { render() { const { navModel, organization } = this.props; const isLoading = Object.keys(organization).length === 0; + const canReadOrg = contextSrv.hasPermission(AccessControlAction.OrgsRead); + const canReadPreferences = contextSrv.hasPermission(AccessControlAction.OrgsPreferencesRead); + const canWritePreferences = contextSrv.hasPermission(AccessControlAction.OrgsPreferencesWrite); return ( {!isLoading && ( - - + {canReadOrg && } + {canReadPreferences && } )} diff --git a/public/app/features/org/OrgProfile.test.tsx b/public/app/features/org/OrgProfile.test.tsx index 216beb001e495..9eceebcee9545 100644 --- a/public/app/features/org/OrgProfile.test.tsx +++ b/public/app/features/org/OrgProfile.test.tsx @@ -2,6 +2,14 @@ import React from 'react'; import { shallow } from 'enzyme'; import OrgProfile, { Props } from './OrgProfile'; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const setup = () => { const props: Props = { orgName: 'Main org', diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx index 7554a3e54656f..f4b9a0bd2935b 100644 --- a/public/app/features/org/OrgProfile.tsx +++ b/public/app/features/org/OrgProfile.tsx @@ -1,5 +1,7 @@ import React, { FC } from 'react'; import { Input, Field, FieldSet, Button, Form } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; export interface Props { orgName: string; @@ -11,10 +13,12 @@ interface FormDTO { } const OrgProfile: FC = ({ onSubmit, orgName }) => { + const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite); + return (
    onSubmit(orgName)}> {({ register }) => ( -
    +
    diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap index 69c6a3db75737..448a4d789ab83 100644 --- a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap +++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap @@ -43,6 +43,7 @@ exports[`Render should render organization and preferences 1`] = ` orgName="Cool org" /> diff --git a/public/app/features/panel/components/CannotVisualizeData.tsx b/public/app/features/panel/components/CannotVisualizeData.tsx deleted file mode 100644 index 51489b95d077a..0000000000000 --- a/public/app/features/panel/components/CannotVisualizeData.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { GrafanaTheme2, VisualizationSuggestion } from '@grafana/data'; -import { useStyles2 } from '../../../../../packages/grafana-ui/src'; -import { css } from '@emotion/css'; - -interface Props { - message: string; - suggestions?: VisualizationSuggestion[]; -} - -export function CannotVisualizeData({ message, suggestions }: Props) { - const styles = useStyles2(getStyles); - - return ( -
    -
    {message}
    - { - // suggestions && ( - //
    - // {suggestions.map((suggestion, index) => ( - // - // ))} - //
    - // ) - } -
    - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - wrapper: css` - display: flex; - align-items: center; - height: 100%; - width: 100%; - `, - message: css` - text-align: center; - color: $text-muted; - font-size: $font-size-lg; - width: 100%; - `, - }; -}; diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx new file mode 100644 index 0000000000000..4520cc1ea09ae --- /dev/null +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { CoreApp, GrafanaTheme2, PanelDataSummary, VisualizationSuggestionsBuilder } from '@grafana/data'; +import { usePanelContext, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { PanelDataErrorViewProps } from '@grafana/runtime'; +import { CardButton } from 'app/core/components/CardButton'; +import { useDispatch } from 'react-redux'; +import { toggleVizPicker } from 'app/features/dashboard/components/PanelEditor/state/reducers'; +import { changePanelPlugin } from '../state/actions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import store from 'app/core/store'; +import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants'; +import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; + +export function PanelDataErrorView(props: PanelDataErrorViewProps) { + const styles = useStyles2(getStyles); + const context = usePanelContext(); + const builder = new VisualizationSuggestionsBuilder(props.data); + const { dataSummary } = builder; + const message = getMessageFor(props, dataSummary); + const dispatch = useDispatch(); + + const openVizPicker = () => { + store.setObject(LS_VISUALIZATION_SELECT_TAB_KEY, VisualizationSelectPaneTab.Suggestions); + dispatch(toggleVizPicker(true)); + }; + + const switchToTable = () => { + const panel = getDashboardSrv().getCurrent()?.getPanelById(props.panelId); + if (!panel) { + return; + } + + dispatch( + changePanelPlugin({ + panel, + pluginId: 'table', + }) + ); + }; + + return ( +
    +
    {message}
    + {context.app === CoreApp.PanelEditor && dataSummary.hasData && ( +
    + + Switch to table + + + Open visualization suggestions + +
    + )} +
    + ); +} + +function getMessageFor( + { data, message, needsNumberField, needsTimeField, needsStringField }: PanelDataErrorViewProps, + dataSummary: PanelDataSummary +): string { + if (message) { + return message; + } + + if (!data.series || data.series.length === 0) { + return 'No data'; + } + + if (needsStringField && !dataSummary.hasStringField) { + return 'Data is missing a string field'; + } + + if (needsNumberField && !dataSummary.hasNumberField) { + return 'Data is missing a number field'; + } + + if (needsTimeField && !dataSummary.hasTimeField) { + return 'Data is missing a time field'; + } + + return 'Cannot visualize data'; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', + }), + message: css({ + textAlign: 'center', + color: theme.colors.text.secondary, + fontSize: theme.typography.size.lg, + width: '100%', + }), + actions: css({ + marginTop: theme.spacing(2), + display: 'flex', + height: '50%', + maxHeight: '150px', + columnGap: theme.spacing(1), + rowGap: theme.spacing(1), + width: '100%', + maxWidth: '600px', + }), + }; +}; diff --git a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx index c672bce90878d..fb4b4aa9f8194 100644 --- a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx @@ -3,7 +3,7 @@ import { GrafanaTheme2, isUnsignedPluginSignature, PanelPluginMeta, PluginState import { IconButton, PluginSignatureBadge, useStyles2 } from '@grafana/ui'; import { css, cx } from '@emotion/css'; import { selectors } from '@grafana/e2e-selectors'; -import { PluginStateInfo } from 'app/features/plugins/PluginStateInfo'; +import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; interface Props { isCurrent: boolean; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx similarity index 54% rename from public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx rename to public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 91a6d57877697..3305e1f64a2cb 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationPreview.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -1,11 +1,11 @@ import React, { CSSProperties } from 'react'; import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data'; import { PanelRenderer } from '../PanelRenderer'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { Tooltip, useStyles2 } from '@grafana/ui'; import { VizTypeChangeDetails } from './types'; -import { cloneDeep } from 'lodash'; import { selectors } from '@grafana/e2e-selectors'; +import { cloneDeep } from 'lodash'; export interface Props { data: PanelData; @@ -15,50 +15,59 @@ export interface Props { onChange: (details: VizTypeChangeDetails) => void; } -export function VisualizationPreview({ data, suggestion, onChange, width, showTitle }: Props) { +export function VisualizationSuggestionCard({ data, suggestion, onChange, width, showTitle }: Props) { const styles = useStyles2(getStyles); const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width); + const cardOptions = suggestion.cardOptions ?? {}; - const onClick = () => { - onChange({ - pluginId: suggestion.pluginId, - options: suggestion.options, - fieldConfig: suggestion.fieldConfig, - }); + const commonButtonProps = { + 'aria-label': suggestion.name, + className: styles.vizBox, + 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name), + style: outerStyles, + onClick: () => { + onChange({ + pluginId: suggestion.pluginId, + options: suggestion.options, + fieldConfig: suggestion.fieldConfig, + }); + }, }; + if (cardOptions.imgSrc) { + return ( + + + + ); + } + let preview = suggestion; - if (suggestion.previewModifier) { + if (suggestion.cardOptions?.previewModifier) { preview = cloneDeep(suggestion); - suggestion.previewModifier(preview); + suggestion.cardOptions.previewModifier(preview); } return ( -
    - {showTitle &&
    {suggestion.name}
    } - -
    + ); } @@ -77,8 +86,7 @@ const getStyles = (theme: GrafanaTheme2) => { background: none; border-radius: ${theme.shape.borderRadius(1)}; cursor: pointer; - text-align: left; - border: 1px solid ${theme.colors.border.strong}; + border: 1px solid ${theme.colors.border.medium}; transition: ${theme.transitions.create(['background'], { duration: theme.transitions.duration.short, @@ -88,7 +96,23 @@ const getStyles = (theme: GrafanaTheme2) => { background: ${theme.colors.background.secondary}; } `, + imgBox: css` + display: flex; + flex-direction: column; + height: 100%; + + justify-self: center; + color: ${theme.colors.text.primary}; + width: 100%; + display: flex; + + justify-content: center; + align-items: center; + text-align: center; + `, name: css` + padding-bottom: ${theme.spacing(0.5)}; + margin-top: ${theme.spacing(-1)}; font-size: ${theme.typography.bodySmall.fontSize}; white-space: nowrap; overflow: hidden; @@ -96,6 +120,10 @@ const getStyles = (theme: GrafanaTheme2) => { font-weight: ${theme.typography.fontWeightMedium}; text-overflow: ellipsis; `, + img: css` + max-width: ${theme.spacing(8)}; + max-height: ${theme.spacing(8)}; + `, renderContainer: css` position: absolute; transform-origin: left top; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index 22d4536170789..f8ecb38c7c32c 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -3,7 +3,7 @@ import { useStyles2 } from '@grafana/ui'; import { GrafanaTheme2, PanelData, PanelPluginMeta, PanelModel, VisualizationSuggestion } from '@grafana/data'; import { css } from '@emotion/css'; import { VizTypeChangeDetails } from './types'; -import { VisualizationPreview } from './VisualizationPreview'; +import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { getAllSuggestions } from '../../state/getAllSuggestions'; import { useAsync, useLocalStorage } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -44,7 +44,7 @@ export function VisualizationSuggestions({ onChange, data, panel, searchQuery }:
    {filteredSuggestions.map((suggestion, index) => ( - { ctx.setData([]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); + expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel]); }); }); @@ -73,7 +84,7 @@ scenario('No rows', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Table, SuggestionName.TextPanel, SuggestionName.DashboardList]); + expect(ctx.names()).toEqual([SuggestionName.Table]); }); }); diff --git a/public/app/features/panel/state/getAllSuggestions.ts b/public/app/features/panel/state/getAllSuggestions.ts index c70d750867449..aaecfdf7d0cbc 100644 --- a/public/app/features/panel/state/getAllSuggestions.ts +++ b/public/app/features/panel/state/getAllSuggestions.ts @@ -5,6 +5,7 @@ import { PanelModel, VisualizationSuggestionScore, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; export const panelsToCheckFirst = [ @@ -17,8 +18,6 @@ export const panelsToCheckFirst = [ 'table', 'state-timeline', 'status-history', - 'text', - 'dashlist', 'logs', 'candlestick', ]; @@ -35,7 +34,26 @@ export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): P } } - return builder.getList().sort((a, b) => { + const list = builder.getList(); + + if (builder.dataSummary.fieldCount === 0) { + for (const plugin of Object.values(config.panels)) { + if (!plugin.skipDataQuery || plugin.hideFromList) { + continue; + } + + list.push({ + name: plugin.name, + pluginId: plugin.id, + description: plugin.info.description, + cardOptions: { + imgSrc: plugin.info.logos.small, + }, + }); + } + } + + return list.sort((a, b) => { return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK); }); } diff --git a/public/app/features/plugins/PluginList.test.tsx b/public/app/features/plugins/PluginList.test.tsx deleted file mode 100644 index 2f145706cd7d8..0000000000000 --- a/public/app/features/plugins/PluginList.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import PluginList from './PluginList'; -import { getMockPlugins } from './__mocks__/pluginMocks'; -import { LayoutModes } from '@grafana/data'; - -const setup = (propOverrides?: object) => { - const props = Object.assign( - { - plugins: getMockPlugins(5), - layoutMode: LayoutModes.Grid, - }, - propOverrides - ); - - return shallow(); -}; - -describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); - - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/public/app/features/plugins/PluginList.tsx b/public/app/features/plugins/PluginList.tsx deleted file mode 100644 index 3a155481c2f6c..0000000000000 --- a/public/app/features/plugins/PluginList.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { FC } from 'react'; -import PluginListItem from './PluginListItem'; -import { PluginMeta } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; - -interface Props { - plugins: PluginMeta[]; -} - -const PluginList: FC = (props) => { - const { plugins } = props; - - return ( -
    -
      - {plugins.map((plugin, index) => { - return ; - })} -
    -
    - ); -}; - -export default PluginList; diff --git a/public/app/features/plugins/PluginListItem.test.tsx b/public/app/features/plugins/PluginListItem.test.tsx deleted file mode 100644 index 175911c5e050d..0000000000000 --- a/public/app/features/plugins/PluginListItem.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import PluginListItem from './PluginListItem'; -import { getMockPlugin } from './__mocks__/pluginMocks'; - -const setup = (propOverrides?: object) => { - const props = Object.assign( - { - plugin: getMockPlugin(), - }, - propOverrides - ); - - return shallow(); -}; - -describe('Render', () => { - it('should render component', () => { - const wrapper = setup(); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should render has plugin section', () => { - const mockPlugin = getMockPlugin(); - mockPlugin.hasUpdate = true; - const wrapper = setup({ - plugin: mockPlugin, - }); - - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/public/app/features/plugins/PluginListItem.tsx b/public/app/features/plugins/PluginListItem.tsx deleted file mode 100644 index c0d92d30d9674..0000000000000 --- a/public/app/features/plugins/PluginListItem.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { FC } from 'react'; -import { PluginMeta } from '@grafana/data'; -import { PluginSignatureBadge } from '@grafana/ui'; -import { selectors } from '@grafana/e2e-selectors'; - -interface Props { - plugin: PluginMeta; -} - -const PluginListItem: FC = (props) => { - const { plugin } = props; - - return ( -
  • - -
    -
    {plugin.type}
    -
    - -
    - {plugin.hasUpdate && ( -
    - Update available! -
    - )} -
    -
    -
    - -
    -
    -
    {plugin.name}
    -
    {`By ${plugin.info.author.name}`}
    -
    -
    -
    -
  • - ); -}; - -export default PluginListItem; diff --git a/public/app/features/plugins/PluginListPage.test.tsx b/public/app/features/plugins/PluginListPage.test.tsx deleted file mode 100644 index 03037cfa55c65..0000000000000 --- a/public/app/features/plugins/PluginListPage.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { PluginListPage, Props } from './PluginListPage'; -import { NavModel, PluginErrorCode, PluginMeta } from '@grafana/data'; -import { mockToolkitActionCreator } from 'test/core/redux/mocks'; -import { setPluginsSearchQuery } from './state/reducers'; -import { render, screen, waitFor } from '@testing-library/react'; -import { selectors } from '@grafana/e2e-selectors'; -import { Provider } from 'react-redux'; -import { configureStore } from '../../store/configureStore'; -import { afterEach } from '../../../test/lib/common'; - -let errorsReturnMock: any = []; - -jest.mock('@grafana/runtime', () => { - const original = jest.requireActual('@grafana/runtime'); - const mockedRuntime = { - ...original, - getBackendSrv: () => ({ - get: () => { - return errorsReturnMock as any; - }, - }), - }; - - mockedRuntime.config.pluginAdminEnabled = false; - - return mockedRuntime; -}); - -const setup = (propOverrides?: object) => { - const store = configureStore(); - const props: Props = { - navModel: { - main: { - text: 'Configuration', - }, - node: { - text: 'Plugins', - }, - } as NavModel, - plugins: [] as PluginMeta[], - searchQuery: '', - setPluginsSearchQuery: mockToolkitActionCreator(setPluginsSearchQuery), - loadPlugins: jest.fn(), - hasFetched: false, - }; - - Object.assign(props, propOverrides); - - return render( - - - - ); -}; - -describe('Render', () => { - afterEach(() => { - errorsReturnMock = []; - }); - - it('should render component', async () => { - errorsReturnMock = []; - setup(); - await waitFor(() => { - expect(screen.queryByLabelText(selectors.pages.PluginsList.page)).toBeInTheDocument(); - expect(screen.queryByLabelText(selectors.pages.PluginsList.list)).not.toBeInTheDocument(); - }); - }); - - it('should render list', async () => { - errorsReturnMock = []; - setup({ - hasFetched: true, - }); - await waitFor(() => { - expect(screen.queryByLabelText(selectors.pages.PluginsList.list)).toBeInTheDocument(); - }); - }); - - describe('Plugin signature errors', () => { - it('should render notice if there are plugins with signing errors', async () => { - errorsReturnMock = [{ pluginId: 'invalid-sig', errorCode: PluginErrorCode.invalidSignature }]; - setup({ - hasFetched: true, - }); - - await waitFor(() => - expect(screen.getByLabelText(selectors.pages.PluginsList.signatureErrorNotice)).toBeInTheDocument() - ); - }); - }); -}); diff --git a/public/app/features/plugins/PluginListPage.tsx b/public/app/features/plugins/PluginListPage.tsx deleted file mode 100644 index 885011ac436a0..0000000000000 --- a/public/app/features/plugins/PluginListPage.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { connect, ConnectedProps } from 'react-redux'; -import Page from 'app/core/components/Page/Page'; -import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; -import PluginList from './PluginList'; -import { loadPlugins } from './state/actions'; -import { getNavModel } from 'app/core/selectors/navModel'; -import { getPlugins, getPluginsSearchQuery } from './state/selectors'; -import { StoreState } from 'app/types'; -import { setPluginsSearchQuery } from './state/reducers'; -import { useAsync } from 'react-use'; -import { selectors } from '@grafana/e2e-selectors'; -import { PluginsErrorsInfo } from './PluginsErrorsInfo'; - -const mapStateToProps = (state: StoreState) => ({ - navModel: getNavModel(state.navIndex, 'plugins'), - plugins: getPlugins(state.plugins), - searchQuery: getPluginsSearchQuery(state.plugins), - hasFetched: state.plugins.hasFetched, -}); - -const mapDispatchToProps = { - loadPlugins, - setPluginsSearchQuery, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); -export type Props = ConnectedProps; - -export const PluginListPage: React.FC = ({ - hasFetched, - navModel, - plugins, - setPluginsSearchQuery, - searchQuery, - loadPlugins, -}) => { - useAsync(async () => { - loadPlugins(); - }, [loadPlugins]); - - let actionTarget: string | undefined = '_blank'; - const linkButton = { - href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list', - title: 'Find more plugins on Grafana.com', - }; - - return ( - - - <> - setPluginsSearchQuery(query)} - linkButton={linkButton} - placeholder="Search by name, author, description or type" - target={actionTarget} - /> - - {hasFetched && plugins && } - - - - ); -}; - -export default connect(mapStateToProps, mapDispatchToProps)(PluginListPage); diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx deleted file mode 100644 index a611ab8a72ab5..0000000000000 --- a/public/app/features/plugins/PluginPage.tsx +++ /dev/null @@ -1,585 +0,0 @@ -// Libraries -import React, { PureComponent } from 'react'; -import { capitalize, find } from 'lodash'; -// Types -import { - AppPlugin, - GrafanaPlugin, - GrafanaTheme2, - NavModel, - NavModelItem, - PanelPluginMeta, - PluginDependencies, - PluginInclude, - PluginIncludeType, - PluginMeta, - PluginMetaInfo, - PluginSignatureStatus, - PluginSignatureType, - PluginType, - UrlQueryMap, -} from '@grafana/data'; -import { AppNotificationSeverity } from 'app/types'; -import { Alert, Badge, Icon, LinkButton, PluginSignatureBadge, Tooltip, useStyles2 } from '@grafana/ui'; - -import Page from 'app/core/components/Page/Page'; -import { getPluginSettings } from './PluginSettingsCache'; -import { importAppPlugin, importDataSourcePlugin } from './plugin_loader'; -import { importPanelPluginFromMeta } from './importPanelPlugin'; -import { getNotFoundNav } from 'app/angular/services/nav_model_srv'; -import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; -import { AppConfigCtrlWrapper } from './wrappers/AppConfigWrapper'; -import { PluginDashboards } from './PluginDashboards'; -import { appEvents } from 'app/core/core'; -import { config } from 'app/core/config'; -import { contextSrv } from '../../core/services/context_srv'; -import { css } from '@emotion/css'; -import { selectors } from '@grafana/e2e-selectors'; -import { ShowModalReactEvent } from 'app/types/events'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { UpdatePluginModal } from './UpdatePluginModal'; - -interface Props extends GrafanaRouteComponentProps<{ pluginId: string }, UrlQueryMap> {} - -interface State { - loading: boolean; - plugin?: GrafanaPlugin; - nav: NavModel; - defaultPage: string; // The first configured one or readme -} - -const PAGE_ID_README = 'readme'; -const PAGE_ID_DASHBOARDS = 'dashboards'; -const PAGE_ID_CONFIG_CTRL = 'config'; - -class PluginPage extends PureComponent { - constructor(props: Props) { - super(props); - this.state = { - loading: true, - nav: getLoadingNav(), - defaultPage: PAGE_ID_README, - }; - } - - async componentDidMount() { - try { - const { location, queryParams } = this.props; - const { appSubUrl } = config; - const plugin = await loadPlugin(this.props.match.params.pluginId); - const { defaultPage, nav } = getPluginTabsNav( - plugin, - appSubUrl, - location.pathname, - queryParams, - contextSrv.hasRole('Admin') - ); - this.setState({ - loading: false, - plugin, - defaultPage, - nav, - }); - } catch { - this.setState({ - loading: false, - nav: getNotFoundNav(), - }); - } - } - - componentDidUpdate(prevProps: Props) { - const prevPage = prevProps.queryParams.page as string; - const page = this.props.queryParams.page as string; - - if (prevPage !== page) { - const { nav, defaultPage } = this.state; - const node = { - ...nav.node, - children: setActivePage(page, nav.node.children!, defaultPage), - }; - - this.setState({ - nav: { - node: node, - main: node, - }, - }); - } - } - - renderBody() { - const { queryParams } = this.props; - const { plugin, nav } = this.state; - - if (!plugin) { - return ; - } - - const active = nav.main.children!.find((tab) => tab.active); - if (active) { - // Find the current config tab - if (plugin.configPages) { - for (const tab of plugin.configPages) { - if (tab.id === active.id) { - return ; - } - } - } - - // Apps have some special behavior - if (plugin.meta.type === PluginType.app) { - if (active.id === PAGE_ID_DASHBOARDS) { - return ; - } - - if (active.id === PAGE_ID_CONFIG_CTRL && plugin.angularConfigCtrl) { - return ; - } - } - } - - return ; - } - - showUpdateInfo = () => { - const { id, name } = this.state.plugin!.meta; - appEvents.publish( - new ShowModalReactEvent({ - props: { - id, - name, - }, - component: UpdatePluginModal, - }) - ); - }; - - renderVersionInfo(meta: PluginMeta) { - if (!meta.info.version) { - return null; - } - - return ( -
    -

    Version

    - {meta.info.version} - {meta.hasUpdate && ( -
    - - - Update Available! - - -
    - )} -
    - ); - } - - renderSidebarIncludeBody(item: PluginInclude) { - if (item.type === PluginIncludeType.page) { - const pluginId = this.state.plugin!.meta.id; - const page = item.name.toLowerCase().replace(' ', '-'); - const url = item.path ?? `plugins/${pluginId}/page/${page}`; - return ( - - - {item.name} - - ); - } - return ( - <> - - {item.name} - - ); - } - - renderSidebarIncludes(includes?: PluginInclude[]) { - if (!includes || !includes.length) { - return null; - } - - return ( -
    -

    Includes

    -
      - {includes.map((include) => { - return ( -
    • - {this.renderSidebarIncludeBody(include)} -
    • - ); - })} -
    -
    - ); - } - - renderSidebarDependencies(dependencies?: PluginDependencies) { - if (!dependencies) { - return null; - } - - return ( -
    -

    Dependencies

    -
      -
    • - Grafana logo - Grafana {dependencies.grafanaVersion} -
    • - {dependencies.plugins && - dependencies.plugins.map((plug) => { - return ( -
    • - - {plug.name} {plug.version} -
    • - ); - })} -
    -
    - ); - } - - renderSidebarLinks(info: PluginMetaInfo) { - if (!info.links || !info.links.length) { - return null; - } - - return ( -
    -

    Links

    - -
    - ); - } - - renderPluginNotice() { - const { plugin } = this.state; - - if (!plugin) { - return null; - } - - const isSignatureValid = plugin.meta.signature === PluginSignatureStatus.valid; - - if (plugin.meta.signature === PluginSignatureStatus.internal) { - return null; - } - - return ( - -
    - - {isSignatureValid && ( - - )} -
    -
    -

    - Grafana Labs checks each plugin to verify that it has a valid digital signature. Plugin signature verification - is part of our security measures to ensure plugins are safe and trustworthy.{' '} - {!isSignatureValid && - 'Grafana Labs can’t guarantee the integrity of this unsigned plugin. Ask the plugin author to request it to be signed.'} -

    - - Read more about plugins signing. - -
    - ); - } - - render() { - const { loading, nav, plugin } = this.state; - const isAdmin = contextSrv.hasRole('Admin'); - - return ( - - - {plugin && ( -
    -
    - {plugin.loadError && ( - - <> - Check the server startup logs for more information.
    - If this plugin was loaded from git, make sure it was compiled. - -
    - )} - {this.renderPluginNotice()} - {this.renderBody()} -
    - -
    - )} -
    -
    - ); - } -} - -function getPluginTabsNav( - plugin: GrafanaPlugin, - appSubUrl: string, - path: string, - query: UrlQueryMap, - isAdmin: boolean -): { defaultPage: string; nav: NavModel } { - const { meta } = plugin; - let defaultPage: string | undefined; - const pages: NavModelItem[] = []; - - pages.push({ - text: 'Readme', - icon: 'file-alt', - url: `${appSubUrl}${path}?page=${PAGE_ID_README}`, - id: PAGE_ID_README, - }); - - // We allow non admins to see plugins but only their readme. Config is hidden - // even though the API needs to be public for plugins to work properly. - if (isAdmin) { - // Only show Config/Pages for app - if (meta.type === PluginType.app) { - // Legacy App Config - if (plugin.angularConfigCtrl) { - pages.push({ - text: 'Config', - icon: 'cog', - url: `${appSubUrl}${path}?page=${PAGE_ID_CONFIG_CTRL}`, - id: PAGE_ID_CONFIG_CTRL, - }); - defaultPage = PAGE_ID_CONFIG_CTRL; - } - - if (plugin.configPages) { - for (const page of plugin.configPages) { - pages.push({ - text: page.title, - icon: page.icon, - url: `${appSubUrl}${path}?page=${page.id}`, - id: page.id, - }); - - if (!defaultPage) { - defaultPage = page.id; - } - } - } - - // Check for the dashboard pages - if (find(meta.includes, { type: PluginIncludeType.dashboard })) { - pages.push({ - text: 'Dashboards', - icon: 'apps', - url: `${appSubUrl}${path}?page=${PAGE_ID_DASHBOARDS}`, - id: PAGE_ID_DASHBOARDS, - }); - } - } - } - - if (!defaultPage) { - defaultPage = pages[0].id; // the first tab - } - - const node = { - text: meta.name, - img: meta.info.logos.large, - subTitle: meta.info.author.name, - breadcrumbs: [{ title: 'Plugins', url: 'plugins' }], - url: `${appSubUrl}${path}`, - children: setActivePage(query.page as string, pages, defaultPage!), - }; - - return { - defaultPage: defaultPage!, - nav: { - node: node, - main: node, - }, - }; -} - -function setActivePage(pageId: string, pages: NavModelItem[], defaultPageId: string): NavModelItem[] { - let found = false; - const selected = pageId || defaultPageId; - const changed = pages.map((p) => { - const active = !found && selected === p.id; - if (active) { - found = true; - } - return { ...p, active }; - }); - - if (!found) { - changed[0].active = true; - } - - return changed; -} - -function getPluginIcon(type: string) { - switch (type) { - case 'datasource': - return 'gicon gicon-datasources'; - case 'panel': - return 'icon-gf icon-gf-panel'; - case 'app': - return 'icon-gf icon-gf-apps'; - case 'page': - return 'icon-gf icon-gf-endpoint-tiny'; - case 'dashboard': - return 'gicon gicon-dashboard'; - default: - return 'icon-gf icon-gf-apps'; - } -} - -export function getLoadingNav(): NavModel { - const node = { - text: 'Loading...', - icon: 'icon-gf icon-gf-panel', - }; - return { - node: node, - main: node, - }; -} - -export async function loadPlugin(pluginId: string): Promise { - const info = await getPluginSettings(pluginId); - let result: GrafanaPlugin | undefined; - - if (info.type === PluginType.app) { - result = await importAppPlugin(info); - } - if (info.type === PluginType.datasource) { - result = await importDataSourcePlugin(info); - } - if (info.type === PluginType.panel) { - const panelPlugin = await importPanelPluginFromMeta(info as PanelPluginMeta); - result = (panelPlugin as unknown) as GrafanaPlugin; - } - if (info.type === PluginType.renderer) { - result = { meta: info } as GrafanaPlugin; - } - - if (!result) { - throw new Error('Unknown Plugin type: ' + info.type); - } - - return result; -} - -type PluginSignatureDetailsBadgeProps = { - signatureType?: PluginSignatureType; - signatureOrg?: string; -}; - -const PluginSignatureDetailsBadge: React.FC = ({ signatureType, signatureOrg }) => { - const styles = useStyles2(getDetailsBadgeStyles); - - if (!signatureType && !signatureOrg) { - return null; - } - - const signatureTypeIcon = - signatureType === PluginSignatureType.grafana - ? 'grafana' - : signatureType === PluginSignatureType.commercial || signatureType === PluginSignatureType.community - ? 'shield' - : 'shield-exclamation'; - - const signatureTypeText = signatureType === PluginSignatureType.grafana ? 'Grafana Labs' : capitalize(signatureType); - - return ( - <> - {signatureType && ( - - Level:  - -   - {signatureTypeText} - - } - /> - )} - {signatureOrg && ( - - Signed by: {signatureOrg} - - } - /> - )} - - ); -}; - -const getDetailsBadgeStyles = (theme: GrafanaTheme2) => ({ - badge: css` - background-color: ${theme.colors.background.canvas}; - border-color: ${theme.colors.border.strong}; - color: ${theme.colors.text.secondary}; - margin-left: ${theme.spacing()}; - `, - strong: css` - color: ${theme.colors.text.primary}; - `, - icon: css` - margin-right: ${theme.spacing(0.5)}; - `, -}); - -export default PluginPage; diff --git a/public/app/features/plugins/UpdatePluginModal.tsx b/public/app/features/plugins/UpdatePluginModal.tsx deleted file mode 100644 index 8b125842bff70..0000000000000 --- a/public/app/features/plugins/UpdatePluginModal.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { Modal, useStyles2, VerticalGroup } from '@grafana/ui'; -import { GrafanaTheme2 } from '@grafana/data'; -import { css } from '@emotion/css'; - -export interface UpdatePluginModalProps { - onDismiss: () => void; - id: string; - name: string; -} - -export function UpdatePluginModal({ onDismiss, id, name }: UpdatePluginModalProps): JSX.Element { - const styles = useStyles2(getStyles); - return ( - - - -

    Type the following on the command line to update {name}.

    -
    -            grafana-cli plugins update {id}
    -          
    - - Check out {name} on Grafana.com for README and changelog. - If you do not have access to the command line, ask your Grafana administator. - -
    -

    - grafana logo - Pro tip: To update all plugins at once, type{' '} - grafana-cli plugins update-all on the command line. -

    -
    -
    - ); -} - -function getStyles(theme: GrafanaTheme2) { - return { - small: css` - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.bodySmall.fontWeight}; - `, - weak: css` - color: ${theme.colors.text.disabled}; - font-size: ${theme.typography.bodySmall.fontSize}; - `, - logo: css` - vertical-align: sub; - margin-right: ${theme.spacing(0.3)}; - width: ${theme.spacing(2)}; - `, - codeSmall: css` - white-space: nowrap; - margin: 0 ${theme.spacing(0.25)}; - padding: ${theme.spacing(0.25)}; - `, - }; -} diff --git a/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap b/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap deleted file mode 100644 index 34cc4c7334c98..0000000000000 --- a/public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap +++ /dev/null @@ -1,241 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` -
    -
      - - - - - - -
    -
    -`; diff --git a/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap b/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap deleted file mode 100644 index 05fcd88c55dda..0000000000000 --- a/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap +++ /dev/null @@ -1,114 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render component 1`] = ` -
  • - -
    -
    - panel -
    -
    - -
    -
    -
    -
    - -
    -
    -
    - pretty cool plugin 1 -
    -
    - By Grafana Labs -
    -
    -
    -
    -
  • -`; - -exports[`Render should render has plugin section 1`] = ` -
  • - -
    -
    - panel -
    -
    - -
    -
    - - Update available! - -
    -
    -
    -
    - -
    -
    -
    - pretty cool plugin 1 -
    -
    - By Grafana Labs -
    -
    -
    -
    -
  • -`; diff --git a/public/app/features/plugins/wrappers/AppConfigWrapper.tsx b/public/app/features/plugins/admin/components/AppConfigWrapper.tsx similarity index 100% rename from public/app/features/plugins/wrappers/AppConfigWrapper.tsx rename to public/app/features/plugins/admin/components/AppConfigWrapper.tsx diff --git a/public/app/features/plugins/admin/components/InstallControls/InstallControls.tsx b/public/app/features/plugins/admin/components/InstallControls/InstallControls.tsx new file mode 100644 index 0000000000000..aad623da4ab43 --- /dev/null +++ b/public/app/features/plugins/admin/components/InstallControls/InstallControls.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { css } from '@emotion/css'; + +import { config } from '@grafana/runtime'; +import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, PluginType } from '@grafana/data'; + +import { ExternallyManagedButton } from './ExternallyManagedButton'; +import { InstallControlsButton } from './InstallControlsButton'; +import { CatalogPlugin, PluginStatus, Version } from '../../types'; +import { getExternalManageLink, isInstallControlsEnabled } from '../../helpers'; +import { useIsRemotePluginsAvailable } from '../../state/hooks'; +import { isGrafanaAdmin } from '../../permissions'; + +interface Props { + plugin: CatalogPlugin; + latestCompatibleVersion?: Version; +} + +export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => { + const styles = useStyles2(getStyles); + const isExternallyManaged = config.pluginAdminExternalManageEnabled; + const hasPermission = isGrafanaAdmin(); + const isRemotePluginsAvailable = useIsRemotePluginsAvailable(); + const isCompatible = Boolean(latestCompatibleVersion); + const isInstallControlsDisabled = plugin.isCore || plugin.isDisabled || !isInstallControlsEnabled(); + + const pluginStatus = plugin.isInstalled + ? plugin.hasUpdate + ? PluginStatus.UPDATE + : PluginStatus.UNINSTALL + : PluginStatus.INSTALL; + + if (isInstallControlsDisabled) { + return null; + } + + if (plugin.type === PluginType.renderer) { + return
    Renderer plugins cannot be managed by the Plugin Catalog.
    ; + } + + if (plugin.isEnterprise && !config.licenseInfo?.hasValidLicense) { + return ( + + No valid Grafana Enterprise license detected. + + Learn more + + + ); + } + + if (plugin.isDev) { + return ( +
    This is a development build of the plugin and can't be uninstalled.
    + ); + } + + if (!hasPermission && !isExternallyManaged) { + const message = `You do not have permission to ${pluginStatus} this plugin.`; + return
    {message}
    ; + } + + if (!plugin.isPublished) { + return ( +
    + This plugin is not published to{' '} + + grafana.com/plugins + {' '} + and can't be managed via the catalog. +
    + ); + } + + if (!isCompatible) { + return ( +
    + +  This plugin doesn't support your version of Grafana. +
    + ); + } + + if (isExternallyManaged) { + return ; + } + + if (!isRemotePluginsAvailable) { + return ( +
    + The install controls have been disabled because the Grafana server cannot access grafana.com. +
    + ); + } + + return ( + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + message: css` + color: ${theme.colors.text.secondary}; + `, + }; +}; diff --git a/public/app/features/plugins/admin/components/InstallControls/index.tsx b/public/app/features/plugins/admin/components/InstallControls/index.tsx index faecc95e73986..6d6ba2dc7c9d0 100644 --- a/public/app/features/plugins/admin/components/InstallControls/index.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/index.tsx @@ -1,114 +1 @@ -import React from 'react'; -import { css } from '@emotion/css'; - -import { config } from '@grafana/runtime'; -import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui'; -import { GrafanaTheme2, PluginType } from '@grafana/data'; - -import { ExternallyManagedButton } from './ExternallyManagedButton'; -import { InstallControlsButton } from './InstallControlsButton'; -import { CatalogPlugin, PluginStatus, Version } from '../../types'; -import { getExternalManageLink } from '../../helpers'; -import { useIsRemotePluginsAvailable } from '../../state/hooks'; -import { isGrafanaAdmin } from '../../permissions'; - -interface Props { - plugin: CatalogPlugin; - latestCompatibleVersion?: Version; -} - -export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => { - const styles = useStyles2(getStyles); - const isExternallyManaged = config.pluginAdminExternalManageEnabled; - const hasPermission = isGrafanaAdmin(); - const isRemotePluginsAvailable = useIsRemotePluginsAvailable(); - const isCompatible = Boolean(latestCompatibleVersion); - - const pluginStatus = plugin.isInstalled - ? plugin.hasUpdate - ? PluginStatus.UPDATE - : PluginStatus.UNINSTALL - : PluginStatus.INSTALL; - - if (plugin.isCore || plugin.isDisabled || plugin.type === PluginType.renderer) { - return null; - } - - if (plugin.isEnterprise && !config.licenseInfo?.hasValidLicense) { - return ( - - No valid Grafana Enterprise license detected. - - Learn more - - - ); - } - - if (plugin.isDev) { - return ( -
    This is a development build of the plugin and can't be uninstalled.
    - ); - } - - if (!hasPermission && !isExternallyManaged) { - const message = `You do not have permission to ${pluginStatus} this plugin.`; - return
    {message}
    ; - } - - if (!plugin.isPublished) { - return ( -
    - This plugin is not published to{' '} - - grafana.com/plugins - {' '} - and can't be managed via the catalog. -
    - ); - } - - if (!isCompatible) { - return ( -
    - -  This plugin doesn't support your version of Grafana. -
    - ); - } - - if (isExternallyManaged) { - return ; - } - - if (!isRemotePluginsAvailable) { - return ( -
    - The install controls have been disabled because the Grafana server cannot access grafana.com. -
    - ); - } - - return ( - - ); -}; - -export const getStyles = (theme: GrafanaTheme2) => { - return { - message: css` - color: ${theme.colors.text.secondary}; - `, - }; -}; +export * from './InstallControls'; diff --git a/public/app/features/plugins/PluginDashboards.tsx b/public/app/features/plugins/admin/components/PluginDashboards.tsx similarity index 100% rename from public/app/features/plugins/PluginDashboards.tsx rename to public/app/features/plugins/admin/components/PluginDashboards.tsx diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index 9ebae16ed1ddc..5608e976b4256 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -7,18 +7,18 @@ import { useStyles2 } from '@grafana/ui'; import { CatalogPlugin, PluginTabIds } from '../types'; import { VersionList } from '../components/VersionList'; import { usePluginConfig } from '../hooks/usePluginConfig'; -import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper'; -import { PluginDashboards } from '../../PluginDashboards'; +import { AppConfigCtrlWrapper } from './AppConfigWrapper'; +import { PluginDashboards } from './PluginDashboards'; type Props = { plugin: CatalogPlugin; queryParams: UrlQueryMap; + pageId: string; }; -export function PluginDetailsBody({ plugin, queryParams }: Props): JSX.Element { +export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.Element { const styles = useStyles2(getStyles); const { value: pluginConfig } = usePluginConfig(plugin); - const pageId = queryParams.page; if (pageId === PluginTabIds.OVERVIEW) { return ( diff --git a/public/app/features/plugins/admin/components/PluginListItem.test.tsx b/public/app/features/plugins/admin/components/PluginListItem.test.tsx index 658cc047adef4..fb2ed8558b09d 100644 --- a/public/app/features/plugins/admin/components/PluginListItem.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListItem.test.tsx @@ -59,7 +59,7 @@ describe('PluginListItem', () => { it('renders a card with link, image, name, orgName and badges', () => { render(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin'); const logo = screen.getByRole('img'); expect(logo).toHaveAttribute('src', plugin.info.logos.small); @@ -102,7 +102,7 @@ describe('PluginListItem', () => { it('renders a row with link, image, name, orgName and badges', () => { render(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin?page=overview'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin'); const logo = screen.getByRole('img'); expect(logo).toHaveAttribute('src', plugin.info.logos.small); diff --git a/public/app/features/plugins/admin/components/PluginListItem.tsx b/public/app/features/plugins/admin/components/PluginListItem.tsx index 0928d689b6635..772f5f9a0b8d6 100644 --- a/public/app/features/plugins/admin/components/PluginListItem.tsx +++ b/public/app/features/plugins/admin/components/PluginListItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { CatalogPlugin, PluginIconName, PluginListDisplayMode, PluginTabIds } from '../types'; +import { CatalogPlugin, PluginIconName, PluginListDisplayMode } from '../types'; import { PluginListItemBadges } from './PluginListItemBadges'; import { PluginLogo } from './PluginLogo'; import { Icon, useStyles2 } from '@grafana/ui'; @@ -19,10 +19,7 @@ export function PluginListItem({ plugin, pathName, displayMode = PluginListDispl const isList = displayMode === PluginListDisplayMode.List; return ( - +

    {plugin.name}

    diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index db942ab1b81ff..7c044a2e3b942 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -261,6 +261,8 @@ export function getLatestCompatibleVersion(versions: Version[] | undefined): Ver return latest; } +export const isInstallControlsEnabled = () => config.pluginAdminEnabled; + export const isLocalPluginVisible = (p: LocalPlugin) => isPluginVisible(p.id); export const isRemotePluginVisible = (p: RemotePlugin) => isPluginVisible(p.slug); diff --git a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx index 8f8e5da594c02..43a2fe47809bc 100644 --- a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx @@ -1,6 +1,6 @@ import { useAsync } from 'react-use'; import { CatalogPlugin } from '../types'; -import { loadPlugin } from '../../PluginPage'; +import { loadPlugin } from '../../utils'; export const usePluginConfig = (plugin?: CatalogPlugin) => { return useAsync(async () => { diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index ffcb305978944..6a045fca1a485 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -9,15 +9,18 @@ type ReturnType = { error: Error | undefined; loading: boolean; tabs: PluginDetailsTab[]; + defaultTab: string; }; export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: PluginDetailsTab[] = []): ReturnType => { const { loading, error, value: pluginConfig } = usePluginConfig(plugin); const isPublished = Boolean(plugin?.isPublished); const { pathname } = useLocation(); - const tabs = useMemo(() => { + + const [tabs, defaultTab] = useMemo(() => { const canConfigurePlugins = isOrgAdmin(); const tabs: PluginDetailsTab[] = [...defaultTabs]; + let defaultTab; if (isPublished) { tabs.push({ @@ -30,7 +33,8 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin // Not extending the tabs with the config pages if the plugin is not installed if (!pluginConfig) { - return tabs; + defaultTab = PluginTabIds.OVERVIEW; + return [tabs, defaultTab]; } if (canConfigurePlugins) { @@ -42,6 +46,7 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin id: PluginTabIds.CONFIG, href: `${pathname}?page=${PluginTabIds.CONFIG}`, }); + defaultTab = PluginTabIds.CONFIG; } if (pluginConfig.configPages) { @@ -52,6 +57,9 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin id: page.id, href: `${pathname}?page=${page.id}`, }); + if (!defaultTab) { + defaultTab = page.id; + } } } @@ -66,12 +74,17 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin } } - return tabs; + if (!defaultTab) { + defaultTab = PluginTabIds.OVERVIEW; + } + + return [tabs, defaultTab]; }, [pluginConfig, defaultTabs, pathname, isPublished]); return { error, loading, tabs, + defaultTab, }; }; diff --git a/public/app/features/plugins/admin/hooks/usePlugins.tsx b/public/app/features/plugins/admin/hooks/usePlugins.tsx deleted file mode 100644 index 475bf1e4330d1..0000000000000 --- a/public/app/features/plugins/admin/hooks/usePlugins.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useMemo } from 'react'; -import { useAsync } from 'react-use'; -import { CatalogPlugin, CatalogPluginsState } from '../types'; -import { api } from '../api'; -import { mapLocalToCatalog, mapRemoteToCatalog, mapToCatalogPlugin } from '../helpers'; - -export function usePlugins(): CatalogPluginsState { - const { loading, value, error } = useAsync(async () => { - const remote = await api.getRemotePlugins(); - const installed = await api.getInstalledPlugins(); - return { remote, installed }; - }, []); - - const plugins = useMemo(() => { - const installed = value?.installed || []; - const remote = value?.remote || []; - const unique: Record = {}; - - for (const plugin of installed) { - unique[plugin.id] = mapLocalToCatalog(plugin); - } - - for (const plugin of remote) { - if (plugin.typeCode === 'renderer') { - continue; - } - - if (!Boolean(plugin.versionSignatureType)) { - continue; - } - - if (unique[plugin.slug]) { - unique[plugin.slug] = mapToCatalogPlugin( - installed.find((installedPlugin) => installedPlugin.id === plugin.slug), - plugin - ); - } else { - unique[plugin.slug] = mapRemoteToCatalog(plugin); - } - } - return Object.values(unique); - }, [value?.installed, value?.remote]); - - return { - loading, - error, - plugins, - }; -} diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index b199ad581cff5..2057a28ca9719 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -40,7 +40,7 @@ jest.mock('../hooks/usePluginConfig.tsx', () => ({ const renderPluginDetails = ( pluginOverride: Partial, { - pageId = PluginTabIds.OVERVIEW, + pageId, pluginsStateOverride, }: { pageId?: PluginTabIds; @@ -55,7 +55,7 @@ const renderPluginDetails = ( location: { hash: '', pathname: `/plugins/${id}`, - search: `?page=${pageId}`, + search: pageId ? `?page=${pageId}` : '', state: undefined, }, }); @@ -118,11 +118,11 @@ describe('Plugin details page', () => { const props = getRouteComponentProps({ match: { params: { pluginId: id }, isExact: true, url: '', path: '' }, - queryParams: { page: PluginTabIds.OVERVIEW }, + queryParams: {}, location: { hash: '', pathname: `/plugins/${id}`, - search: `?page=${PluginTabIds.OVERVIEW}`, + search: '', state: undefined, }, }); @@ -145,6 +145,40 @@ describe('Plugin details page', () => { await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument()); }); + it('should display an app config page by default for installed app plugins', async () => { + const name = 'Akumuli'; + + // @ts-ignore + usePluginConfig.mockReturnValue({ + value: { + meta: { + type: PluginType.app, + enabled: false, + pinned: false, + jsonData: {}, + }, + configPages: [ + { + title: 'Config', + icon: 'cog', + id: 'configPage', + body: function ConfigPage() { + return
    Custom Config Page!
    ; + }, + }, + ], + }, + }); + + const { queryByText } = renderPluginDetails({ + name, + isInstalled: true, + type: PluginType.app, + }); + + await waitFor(() => expect(queryByText(/custom config page/i)).toBeInTheDocument()); + }); + it('should display the number of downloads in the header', async () => { // depending on what locale you have the Intl.NumberFormat will return a format that contains // whitespaces. In that case we don't want testing library to remove whitespaces. @@ -459,6 +493,25 @@ describe('Plugin details page', () => { expect(rendered.getByText(message)).toBeInTheDocument(); }); + it('should not display the install / uninstall / update buttons if `pluginAdminEnabled` flag is set to FALSE in the Grafana config', async () => { + let rendered: RenderResult; + + // Disable the install controls for the plugins catalog + config.pluginAdminEnabled = false; + + // Should not show an "Install" button + rendered = renderPluginDetails({ id, isInstalled: false }); + await waitFor(() => expect(rendered.queryByRole('button', { name: /^install/i })).not.toBeInTheDocument()); + + // Should not show an "Uninstall" button + rendered = renderPluginDetails({ id, isInstalled: true }); + await waitFor(() => expect(rendered.queryByRole('button', { name: /^uninstall/i })).not.toBeInTheDocument()); + + // Should not show an "Update" button + rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true }); + await waitFor(() => expect(rendered.queryByRole('button', { name: /^update/i })).not.toBeInTheDocument()); + }); + it('should display a "Create" button as a post installation step for installed data source plugins', async () => { const name = 'Akumuli'; const { queryByText } = renderPluginDetails({ diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index e8bd18aee359f..8674ca21f0d38 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -25,7 +25,6 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen params: { pluginId = '' }, url, } = match; - const pageId = (queryParams.page as PluginTabIds) || PluginTabIds.OVERVIEW; const parentUrl = url.substring(0, url.lastIndexOf('/')); const defaultTabs: PluginDetailsTab[] = [ { @@ -36,11 +35,12 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen }, ]; const plugin = useGetSingle(pluginId); // fetches the localplugin settings - const { tabs } = usePluginDetailsTabs(plugin, defaultTabs); + const { tabs, defaultTab } = usePluginDetailsTabs(plugin, defaultTabs); const { isLoading: isFetchLoading } = useFetchStatus(); const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus(); const styles = useStyles2(getStyles); const prevTabs = usePrevious(tabs); + const pageId = (queryParams.page as PluginTabIds) || defaultTab; // If an app plugin is uninstalled we need to reset the active tab when the config / dashboards tabs are removed. useEffect(() => { @@ -95,7 +95,7 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen - + diff --git a/public/app/features/plugins/admin/routes.ts b/public/app/features/plugins/admin/routes.ts new file mode 100644 index 0000000000000..f2f9f6317cab7 --- /dev/null +++ b/public/app/features/plugins/admin/routes.ts @@ -0,0 +1,48 @@ +import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; +import { RouteDescriptor } from 'app/core/navigation/types'; +import { isGrafanaAdmin } from './permissions'; +import { PluginAdminRoutes } from './types'; + +const DEFAULT_ROUTES = [ + { + path: '/plugins', + routeName: PluginAdminRoutes.Home, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), + }, + { + path: '/plugins/browse', + routeName: PluginAdminRoutes.Browse, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), + }, + { + path: '/plugins/:pluginId/', + routeName: PluginAdminRoutes.Details, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')), + }, +]; + +const ADMIN_ROUTES = [ + { + path: '/admin/plugins', + routeName: PluginAdminRoutes.HomeAdmin, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), + }, + { + path: '/admin/plugins/browse', + routeName: PluginAdminRoutes.BrowseAdmin, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), + }, + { + path: '/admin/plugins/:pluginId/', + routeName: PluginAdminRoutes.DetailsAdmin, + component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')), + }, +]; + +export function getRoutes(): RouteDescriptor[] { + if (isGrafanaAdmin()) { + return [...DEFAULT_ROUTES, ...ADMIN_ROUTES]; + } + + return DEFAULT_ROUTES; +} diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index 0f7b6e2f01abe..e6eaae31789e6 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { PluginError } from '@grafana/data'; import { setDisplayMode } from './reducer'; import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall } from './actions'; import { CatalogPlugin, PluginCatalogStoreState, PluginListDisplayMode } from '../types'; @@ -11,6 +12,7 @@ import { selectRequestError, selectIsRequestNotFetched, selectDisplayMode, + selectPluginErrors, } from './selectors'; import { sortPlugins, Sorters } from '../helpers'; @@ -53,6 +55,12 @@ export const useGetSingle = (id: string): CatalogPlugin | undefined => { return useSelector((state: PluginCatalogStoreState) => selectById(state, id)); }; +export const useGetErrors = (): PluginError[] => { + useFetchAll(); + + return useSelector(selectPluginErrors); +}; + export const useInstall = () => { const dispatch = useDispatch(); return (id: string, version?: string, isUpdating?: boolean) => dispatch(install({ id, version, isUpdating })); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index 74b6a03c054be..565b84973f8b6 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -1,4 +1,4 @@ -import { createSlice, createEntityAdapter, AnyAction, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createEntityAdapter, Reducer, AnyAction, PayloadAction } from '@reduxjs/toolkit'; import { fetchAll, fetchDetails, install, uninstall, loadPluginDashboards, panelPluginLoaded } from './actions'; import { CatalogPlugin, PluginListDisplayMode, ReducerState, RequestStatus } from '../types'; import { STATE_PREFIX } from '../constants'; @@ -97,4 +97,4 @@ const slice = createSlice({ }); export const { setDisplayMode } = slice.actions; -export const { reducer } = slice; +export const reducer: Reducer = slice.reducer; diff --git a/public/app/features/plugins/admin/state/selectors.ts b/public/app/features/plugins/admin/state/selectors.ts index 8e2fc47682994..a0dae21aba9e2 100644 --- a/public/app/features/plugins/admin/state/selectors.ts +++ b/public/app/features/plugins/admin/state/selectors.ts @@ -1,4 +1,5 @@ -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; +import { PluginError, PluginErrorCode } from '@grafana/data'; import { RequestStatus, PluginCatalogStoreState } from '../types'; import { pluginsAdapter } from './reducer'; @@ -49,6 +50,20 @@ export const find = (searchBy: string, filterBy: string, filterByType: string) = } ); +export const selectPluginErrors = createSelector(selectAll, (plugins) => + plugins + ? plugins + .filter((p) => Boolean(p.error)) + .map( + (p): PluginError => ({ + pluginId: p.id, + errorCode: p!.error as PluginErrorCode, + }) + ) + : [] +); + +// The following selectors are used to get information about the outstanding or completed plugins-related network requests. export const selectRequest = (actionType: string) => createSelector(selectRoot, ({ requests = {} }) => requests[actionType]); diff --git a/public/app/features/plugins/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx similarity index 93% rename from public/app/features/plugins/AppRootPage.test.tsx rename to public/app/features/plugins/components/AppRootPage.test.tsx index b0cc93fa0873e..cf6609f3c3df4 100644 --- a/public/app/features/plugins/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -1,19 +1,19 @@ import { act, render, screen } from '@testing-library/react'; import React, { Component } from 'react'; import AppRootPage from './AppRootPage'; -import { getPluginSettings } from './PluginSettingsCache'; -import { importAppPlugin } from './plugin_loader'; -import { getMockPlugin } from './__mocks__/pluginMocks'; +import { getPluginSettings } from '../pluginSettings'; +import { importAppPlugin } from '../plugin_loader'; +import { getMockPlugin } from '../__mocks__/pluginMocks'; import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data'; import { Route, Router } from 'react-router-dom'; import { locationService, setEchoSrv } from '@grafana/runtime'; import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; import { Echo } from 'app/core/services/echo/Echo'; -jest.mock('./PluginSettingsCache', () => ({ +jest.mock('../pluginSettings', () => ({ getPluginSettings: jest.fn(), })); -jest.mock('./plugin_loader', () => ({ +jest.mock('../plugin_loader', () => ({ importAppPlugin: jest.fn(), })); diff --git a/public/app/features/plugins/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx similarity index 96% rename from public/app/features/plugins/AppRootPage.tsx rename to public/app/features/plugins/components/AppRootPage.tsx index 5d39c6e4eb62f..faa63014b8f8b 100644 --- a/public/app/features/plugins/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -4,8 +4,8 @@ import { AppEvents, AppPlugin, AppPluginMeta, KeyValue, NavModel, PluginType } f import { createHtmlPortalNode, InPortal, OutPortal, HtmlPortalNode } from 'react-reverse-portal'; import Page from 'app/core/components/Page/Page'; -import { getPluginSettings } from './PluginSettingsCache'; -import { importAppPlugin } from './plugin_loader'; +import { getPluginSettings } from '../pluginSettings'; +import { importAppPlugin } from '../plugin_loader'; import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv'; import { appEvents } from 'app/core/core'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; diff --git a/public/app/features/plugins/PluginStateInfo.tsx b/public/app/features/plugins/components/PluginStateInfo.tsx similarity index 100% rename from public/app/features/plugins/PluginStateInfo.tsx rename to public/app/features/plugins/components/PluginStateInfo.tsx diff --git a/public/app/features/plugins/PluginsErrorsInfo.tsx b/public/app/features/plugins/components/PluginsErrorsInfo.tsx similarity index 61% rename from public/app/features/plugins/PluginsErrorsInfo.tsx rename to public/app/features/plugins/components/PluginsErrorsInfo.tsx index bbaaffce88554..6be74a424e10d 100644 --- a/public/app/features/plugins/PluginsErrorsInfo.tsx +++ b/public/app/features/plugins/components/PluginsErrorsInfo.tsx @@ -1,42 +1,19 @@ import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { HorizontalGroup, InfoBox, List, PluginSignatureBadge, useTheme } from '@grafana/ui'; -import { StoreState } from '../../types'; -import { getAllPluginsErrors } from './state/selectors'; -import { loadPlugins, loadPluginsErrors } from './state/actions'; -import useAsync from 'react-use/lib/useAsync'; -import { connect, ConnectedProps } from 'react-redux'; +import { useGetErrors, useFetchStatus } from '../admin/state/hooks'; import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data'; import { css } from '@emotion/css'; -const mapStateToProps = (state: StoreState) => ({ - errors: getAllPluginsErrors(state.plugins), -}); - -const mapDispatchToProps = { - loadPluginsErrors, -}; - -interface OwnProps { - children?: React.ReactNode; -} -const connector = connect(mapStateToProps, mapDispatchToProps); -type PluginsErrorsInfoProps = ConnectedProps & OwnProps; - -export const PluginsErrorsInfoUnconnected: React.FC = ({ - loadPluginsErrors, - errors, - children, -}) => { +export function PluginsErrorsInfo(): React.ReactElement | null { + const errors = useGetErrors(); + const { isLoading } = useFetchStatus(); const theme = useTheme(); - const { loading } = useAsync(async () => { - await loadPluginsErrors(); - }, [loadPlugins]); - - if (loading || errors.length === 0) { + if (isLoading || errors.length === 0) { return null; } + return ( = ({ className={css` list-style-type: circle; `} - renderItem={(e) => ( + renderItem={(error) => (
    - {e.pluginId} + {error.pluginId} = ({
    )} /> - {children}
    ); -}; - -export const PluginsErrorsInfo = connect(mapStateToProps, mapDispatchToProps)(PluginsErrorsInfoUnconnected); +} function mapPluginErrorCodeToSignatureStatus(code: PluginErrorCode) { switch (code) { diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 44a2500c82bff..7db01beddeb17 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -62,7 +62,10 @@ export class DatasourceSrv implements DataSourceService { return this.settingsMapByUid[uid]; } - getInstanceSettings(ref: string | null | undefined | DataSourceRef): DataSourceInstanceSettings | undefined { + getInstanceSettings( + ref: string | null | undefined | DataSourceRef, + scopedVars?: ScopedVars + ): DataSourceInstanceSettings | undefined { const isstring = typeof ref === 'string'; let nameOrUid = isstring ? (ref as string) : ((ref as any)?.uid as string | undefined); @@ -81,7 +84,7 @@ export class DatasourceSrv implements DataSourceService { // Complex logic to support template variable data source names // For this we just pick the current or first data source in the variable if (nameOrUid[0] === '$') { - const interpolatedName = this.templateSrv.replace(nameOrUid, {}, variableInterpolation); + const interpolatedName = this.templateSrv.replace(nameOrUid, scopedVars, variableInterpolation); let dsSettings; @@ -265,15 +268,24 @@ export class DatasourceSrv implements DataSourceService { if (!filters.pluginId && !filters.alerting) { if (filters.mixed) { - base.push(this.getInstanceSettings('-- Mixed --')!); + const mixedInstanceSettings = this.getInstanceSettings('-- Mixed --'); + if (mixedInstanceSettings) { + base.push(mixedInstanceSettings); + } } if (filters.dashboard) { - base.push(this.getInstanceSettings('-- Dashboard --')!); + const dashboardInstanceSettings = this.getInstanceSettings('-- Dashboard --'); + if (dashboardInstanceSettings) { + base.push(dashboardInstanceSettings); + } } if (!filters.tracing) { - base.push(this.getInstanceSettings('-- Grafana --')!); + const grafanaInstanceSettings = this.getInstanceSettings('-- Grafana --'); + if (grafanaInstanceSettings) { + base.push(grafanaInstanceSettings); + } } } diff --git a/public/app/features/plugins/PluginSettingsCache.ts b/public/app/features/plugins/pluginSettings.ts similarity index 100% rename from public/app/features/plugins/PluginSettingsCache.ts rename to public/app/features/plugins/pluginSettings.ts diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index c7523e25364c5..8bd0ded2976cd 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -5,7 +5,6 @@ import kbn from 'app/core/utils/kbn'; import moment from 'moment'; // eslint-disable-line no-restricted-imports import angular from 'angular'; import jquery from 'jquery'; -import * as tslib from 'tslib'; // Experimental module exports import prismjs from 'prismjs'; @@ -82,7 +81,6 @@ function exposeToPlugin(name: string, component: any) { }); } -exposeToPlugin('tslib', tslib); exposeToPlugin('@grafana/data', grafanaData); exposeToPlugin('@grafana/ui', grafanaUI); exposeToPlugin('@grafana/runtime', grafanaRuntime); diff --git a/public/app/features/plugins/routes.ts b/public/app/features/plugins/routes.ts deleted file mode 100644 index 1bd5bfdecb66e..0000000000000 --- a/public/app/features/plugins/routes.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; -import { config } from 'app/core/config'; -import { RouteDescriptor } from 'app/core/navigation/types'; -import { isGrafanaAdmin } from './admin/permissions'; -import { PluginAdminRoutes } from './admin/types'; - -const pluginAdminRoutes = [ - { - path: '/plugins', - routeName: PluginAdminRoutes.Home, - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), - }, - { - path: '/plugins/browse', - routeName: PluginAdminRoutes.Browse, - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), - }, - { - path: '/plugins/:pluginId/', - routeName: PluginAdminRoutes.Details, - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')), - }, -]; - -export function getPluginsAdminRoutes(cfg = config): RouteDescriptor[] { - if (!cfg.pluginAdminEnabled) { - return [ - { - path: '/plugins', - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './PluginListPage')), - }, - { - path: '/plugins/browse', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "PluginAdminNotEnabled" */ './admin/pages/NotEnabed') - ), - }, - { - path: '/plugins/:pluginId/', - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './PluginPage')), - }, - ]; - } - - if (isGrafanaAdmin()) { - return [ - ...pluginAdminRoutes, - { - path: '/admin/plugins', - routeName: PluginAdminRoutes.HomeAdmin, - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), - }, - { - path: '/admin/plugins/browse', - routeName: PluginAdminRoutes.BrowseAdmin, - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')), - }, - { - path: '/admin/plugins/:pluginId/', - routeName: PluginAdminRoutes.DetailsAdmin, - component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')), - }, - ]; - } - - return pluginAdminRoutes; -} diff --git a/public/app/features/plugins/state/actions.ts b/public/app/features/plugins/state/actions.ts deleted file mode 100644 index dcabcc58b4b69..0000000000000 --- a/public/app/features/plugins/state/actions.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { PanelPlugin } from '@grafana/data'; -import { ThunkResult } from 'app/types'; -import { config } from 'app/core/config'; -import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; -import { - loadPanelPlugin as loadPanelPluginNew, - loadPluginDashboards as loadPluginDashboardsNew, -} from '../admin/state/actions'; -import { - pluginDashboardsLoad, - pluginDashboardsLoaded, - pluginsLoaded, - panelPluginLoaded, - pluginsErrorsLoaded, -} from './reducers'; - -export function loadPlugins(): ThunkResult { - return async (dispatch) => { - const plugins = await getBackendSrv().get('api/plugins', { embedded: 0 }); - dispatch(pluginsLoaded(plugins)); - }; -} - -export function loadPluginsErrors(): ThunkResult { - return async (dispatch) => { - const errors = await getBackendSrv().get('api/plugins/errors'); - dispatch(pluginsErrorsLoaded(errors)); - }; -} - -function loadPluginDashboardsOriginal(): ThunkResult { - return async (dispatch, getStore) => { - dispatch(pluginDashboardsLoad()); - const dataSourceType = getStore().dataSources.dataSource.type; - const response = await getBackendSrv().get(`api/plugins/${dataSourceType}/dashboards`); - dispatch(pluginDashboardsLoaded(response)); - }; -} - -function loadPanelPluginOriginal(pluginId: string): ThunkResult> { - return async (dispatch, getStore) => { - let plugin = getStore().plugins.panels[pluginId]; - - if (!plugin) { - plugin = await importPanelPlugin(pluginId); - - // second check to protect against raise condition - if (!getStore().plugins.panels[pluginId]) { - dispatch(panelPluginLoaded(plugin)); - } - } - - return plugin; - }; -} - -export const loadPluginDashboards = config.pluginAdminEnabled ? loadPluginDashboardsNew : loadPluginDashboardsOriginal; -export const loadPanelPlugin = config.pluginAdminEnabled ? loadPanelPluginNew : loadPanelPluginOriginal; diff --git a/public/app/features/plugins/state/reducers.test.ts b/public/app/features/plugins/state/reducers.test.ts deleted file mode 100644 index 1ef517f7433cf..0000000000000 --- a/public/app/features/plugins/state/reducers.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Reducer, AnyAction } from '@reduxjs/toolkit'; -import { reducerTester } from '../../../../test/core/redux/reducerTester'; -import { PluginsState } from '../../../types'; -import { - initialState, - pluginDashboardsLoad, - pluginDashboardsLoaded, - pluginsLoaded, - pluginsReducer, - setPluginsSearchQuery, -} from './reducers'; -import { PluginMetaInfo, PluginType } from '@grafana/data'; - -// Mock the config to enable the old version of the plugins page -jest.mock('@grafana/runtime', () => { - const original = jest.requireActual('@grafana/runtime'); - const mockedRuntime = { ...original }; - - mockedRuntime.config.pluginAdminEnabled = false; - - return mockedRuntime; -}); - -describe('pluginsReducer', () => { - describe('when pluginsLoaded is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(pluginsReducer as Reducer, { ...initialState }) - .whenActionIsDispatched( - pluginsLoaded([ - { - id: 'some-id', - baseUrl: 'some-url', - module: 'some module', - name: 'Some Plugin', - type: PluginType.app, - info: {} as PluginMetaInfo, - }, - ]) - ) - .thenStateShouldEqual({ - ...initialState, - hasFetched: true, - plugins: [ - { - baseUrl: 'some-url', - id: 'some-id', - info: {} as PluginMetaInfo, - module: 'some module', - name: 'Some Plugin', - type: PluginType.app, - }, - ], - errors: [], - }); - }); - }); - - describe('when setPluginsSearchQuery is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(pluginsReducer as Reducer, { ...initialState }) - .whenActionIsDispatched(setPluginsSearchQuery('A query')) - .thenStateShouldEqual({ - ...initialState, - searchQuery: 'A query', - }); - }); - }); - - describe('when pluginDashboardsLoad is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(pluginsReducer as Reducer, { - ...initialState, - dashboards: [ - { - dashboardId: 1, - title: 'Some Dash', - description: 'Some Desc', - folderId: 2, - imported: false, - importedRevision: 1, - importedUri: 'some-uri', - importedUrl: 'some-url', - path: 'some/path', - pluginId: 'some-plugin-id', - removed: false, - revision: 22, - slug: 'someSlug', - }, - ], - }) - .whenActionIsDispatched(pluginDashboardsLoad()) - .thenStateShouldEqual({ - ...initialState, - dashboards: [], - isLoadingPluginDashboards: true, - }); - }); - }); - - describe('when pluginDashboardsLoad is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(pluginsReducer as Reducer, { - ...initialState, - isLoadingPluginDashboards: true, - }) - .whenActionIsDispatched( - pluginDashboardsLoaded([ - { - dashboardId: 1, - title: 'Some Dash', - description: 'Some Desc', - folderId: 2, - imported: false, - importedRevision: 1, - importedUri: 'some-uri', - importedUrl: 'some-url', - path: 'some/path', - pluginId: 'some-plugin-id', - removed: false, - revision: 22, - slug: 'someSlug', - }, - ]) - ) - .thenStateShouldEqual({ - ...initialState, - dashboards: [ - { - dashboardId: 1, - title: 'Some Dash', - description: 'Some Desc', - folderId: 2, - imported: false, - importedRevision: 1, - importedUri: 'some-uri', - importedUrl: 'some-url', - path: 'some/path', - pluginId: 'some-plugin-id', - removed: false, - revision: 22, - slug: 'someSlug', - }, - ], - isLoadingPluginDashboards: false, - }); - }); - }); -}); diff --git a/public/app/features/plugins/state/reducers.ts b/public/app/features/plugins/state/reducers.ts deleted file mode 100644 index 7b366ca208bf0..0000000000000 --- a/public/app/features/plugins/state/reducers.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { AnyAction, createSlice, PayloadAction, Reducer } from '@reduxjs/toolkit'; -import { PluginMeta, PanelPlugin, PluginError } from '@grafana/data'; -import { PluginsState } from 'app/types'; -import { config } from 'app/core/config'; -import { reducer as pluginCatalogReducer } from '../admin/state/reducer'; -import { PluginDashboard } from '../../../types/plugins'; - -export const initialState: PluginsState = { - plugins: [], - errors: [], - searchQuery: '', - hasFetched: false, - dashboards: [], - isLoadingPluginDashboards: false, - panels: {}, -}; - -const pluginsSlice = createSlice({ - name: 'plugins', - initialState, - reducers: { - pluginsLoaded: (state, action: PayloadAction) => { - state.hasFetched = true; - state.plugins = action.payload; - }, - pluginsErrorsLoaded: (state, action: PayloadAction) => { - state.errors = action.payload; - }, - setPluginsSearchQuery: (state, action: PayloadAction) => { - state.searchQuery = action.payload; - }, - pluginDashboardsLoad: (state, action: PayloadAction) => { - state.isLoadingPluginDashboards = true; - state.dashboards = []; - }, - pluginDashboardsLoaded: (state, action: PayloadAction) => { - state.isLoadingPluginDashboards = false; - state.dashboards = action.payload; - }, - panelPluginLoaded: (state, action: PayloadAction) => { - state.panels[action.payload.meta!.id] = action.payload; - }, - }, -}); - -export const { - pluginsLoaded, - pluginsErrorsLoaded, - pluginDashboardsLoad, - pluginDashboardsLoaded, - setPluginsSearchQuery, - panelPluginLoaded, -} = pluginsSlice.actions; - -export const pluginsReducer: Reducer = config.pluginAdminEnabled - ? ((pluginCatalogReducer as unknown) as Reducer) - : pluginsSlice.reducer; - -export default { - plugins: pluginsReducer, -}; diff --git a/public/app/features/plugins/state/selectors.test.ts b/public/app/features/plugins/state/selectors.test.ts deleted file mode 100644 index 4f2f77efe8ae8..0000000000000 --- a/public/app/features/plugins/state/selectors.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getPlugins, getPluginsSearchQuery } from './selectors'; -import { initialState } from './reducers'; -import { getMockPlugins } from '../__mocks__/pluginMocks'; - -describe('Selectors', () => { - const mockState = { ...initialState }; - - it('should return search query', () => { - mockState.searchQuery = 'test'; - const query = getPluginsSearchQuery(mockState); - - expect(query).toEqual(mockState.searchQuery); - }); - - it('should return plugins', () => { - mockState.plugins = getMockPlugins(5); - mockState.searchQuery = ''; - - const plugins = getPlugins(mockState); - - expect(plugins).toEqual(mockState.plugins); - }); - - it('should filter plugins', () => { - mockState.searchQuery = 'plugin-1'; - - const plugins = getPlugins(mockState); - - expect(plugins.length).toEqual(1); - }); -}); diff --git a/public/app/features/plugins/state/selectors.ts b/public/app/features/plugins/state/selectors.ts deleted file mode 100644 index 774844ba7232d..0000000000000 --- a/public/app/features/plugins/state/selectors.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PluginsState } from 'app/types/plugins'; - -export const getPlugins = (state: PluginsState) => { - const regex = new RegExp(state.searchQuery, 'i'); - - return state.plugins.filter((item) => { - return ( - regex.test(item.name) || - regex.test(item.info.author.name) || - regex.test(item.type) || - regex.test(item.info.description) - ); - }); -}; -export const getAllPluginsErrors = (state: PluginsState) => { - return state.errors; -}; - -export const getPluginsSearchQuery = (state: PluginsState) => state.searchQuery; diff --git a/public/app/features/plugins/datasource_srv.test.ts b/public/app/features/plugins/tests/datasource_srv.test.ts similarity index 89% rename from public/app/features/plugins/datasource_srv.test.ts rename to public/app/features/plugins/tests/datasource_srv.test.ts index a87d5a1689173..9af0de14ff1a4 100644 --- a/public/app/features/plugins/datasource_srv.test.ts +++ b/public/app/features/plugins/tests/datasource_srv.test.ts @@ -1,5 +1,11 @@ import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { DataSourceApi, DataSourceInstanceSettings, DataSourcePlugin, DataSourcePluginMeta } from '@grafana/data'; +import { + DataSourceApi, + DataSourceInstanceSettings, + DataSourcePlugin, + DataSourcePluginMeta, + ScopedVar, +} from '@grafana/data'; // Datasource variable $datasource with current value 'BBB' const templateSrv: any = { @@ -19,7 +25,11 @@ const templateSrv: any = { }, }, ], - replace: (v: string) => { + replace: (v: string, scopedVars: ScopedVar) => { + if (scopedVars && scopedVars.datasource) { + return v.replace('${datasource}', scopedVars.datasource.value); + } + let result = v.replace('${datasource}', 'BBB'); result = result.replace('${datasourceDefault}', 'default'); return result; @@ -30,7 +40,7 @@ class TestDataSource { constructor(public instanceSettings: DataSourceInstanceSettings) {} } -jest.mock('./plugin_loader', () => ({ +jest.mock('../plugin_loader', () => ({ importDataSourcePlugin: (meta: DataSourcePluginMeta) => { return Promise.resolve(new DataSourcePlugin(TestDataSource as any)); }, @@ -124,6 +134,14 @@ describe('datasource_srv', () => { expect(instance.uid).toBe('uid-code-mmm'); expect(instance.getRef()).toEqual({ type: 'test-db', uid: 'uid-code-mmm' }); }); + + it('Can get by variable', async () => { + const ds = (await dataSourceSrv.get('${datasource}')) as any; + expect(ds.meta).toBe(dataSourceInit.BBB.meta); + + const ds2 = await dataSourceSrv.get('${datasource}', { datasource: { text: 'Prom', value: 'uid-code-aaa' } }); + expect(ds2.uid).toBe(dataSourceInit.aaa.uid); + }); }); describe('when getting instance settings', () => { @@ -145,6 +163,13 @@ describe('datasource_srv', () => { `); }); + it('should work with variable via scopedVars', () => { + const ds = dataSourceSrv.getInstanceSettings('${datasource}', { + datasource: { text: 'Prom', value: 'uid-code-aaa' }, + }); + expect(ds?.rawRef?.uid).toBe('uid-code-aaa'); + }); + it('should not set isDefault when being fetched via variable', () => { const ds = dataSourceSrv.getInstanceSettings('${datasource}'); expect(ds?.isDefault).toBe(false); diff --git a/public/app/features/plugins/pluginCacheBuster.test.ts b/public/app/features/plugins/tests/pluginCacheBuster.test.ts similarity index 97% rename from public/app/features/plugins/pluginCacheBuster.test.ts rename to public/app/features/plugins/tests/pluginCacheBuster.test.ts index 131d5982809b0..7954cef02783b 100644 --- a/public/app/features/plugins/pluginCacheBuster.test.ts +++ b/public/app/features/plugins/tests/pluginCacheBuster.test.ts @@ -1,4 +1,4 @@ -import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from './pluginCacheBuster'; +import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from '../pluginCacheBuster'; describe('PluginCacheBuster', () => { const now = 12345; diff --git a/public/app/features/plugins/plugin_loader.test.ts b/public/app/features/plugins/tests/plugin_loader.test.ts similarity index 96% rename from public/app/features/plugins/plugin_loader.test.ts rename to public/app/features/plugins/tests/plugin_loader.test.ts index 2d365233710d1..2ddc26bd51f86 100644 --- a/public/app/features/plugins/plugin_loader.test.ts +++ b/public/app/features/plugins/tests/plugin_loader.test.ts @@ -17,7 +17,7 @@ import { SystemJS } from '@grafana/runtime'; import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data'; // Loaded after the `unmock` abve -import { importAppPlugin } from './plugin_loader'; +import { importAppPlugin } from '../plugin_loader'; class MyCustomApp extends AppPlugin { initWasCalled = false; diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts new file mode 100644 index 0000000000000..b043b2ca45f21 --- /dev/null +++ b/public/app/features/plugins/utils.ts @@ -0,0 +1,29 @@ +import { GrafanaPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; +import { getPluginSettings } from './pluginSettings'; +import { importAppPlugin, importDataSourcePlugin } from './plugin_loader'; +import { importPanelPluginFromMeta } from './importPanelPlugin'; + +export async function loadPlugin(pluginId: string): Promise { + const info = await getPluginSettings(pluginId); + let result: GrafanaPlugin | undefined; + + if (info.type === PluginType.app) { + result = await importAppPlugin(info); + } + if (info.type === PluginType.datasource) { + result = await importDataSourcePlugin(info); + } + if (info.type === PluginType.panel) { + const panelPlugin = await importPanelPluginFromMeta(info as PanelPluginMeta); + result = (panelPlugin as unknown) as GrafanaPlugin; + } + if (info.type === PluginType.renderer) { + result = { meta: info } as GrafanaPlugin; + } + + if (!result) { + throw new Error('Unknown Plugin type: ' + info.type); + } + + return result; +} diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index 8b0ce2b93f9b1..d6a0a22a81f76 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -51,7 +51,7 @@ export interface QueryRunnerOptions< maxDataPoints: number; minInterval: string | undefined | null; scopedVars?: ScopedVars; - cacheTimeout?: string; + cacheTimeout?: string | null; transformations?: DataTransformerConfig[]; } diff --git a/public/app/features/search/components/ConfirmDeleteModal.tsx b/public/app/features/search/components/ConfirmDeleteModal.tsx index 2f2172c825db0..34a9dad47221a 100644 --- a/public/app/features/search/components/ConfirmDeleteModal.tsx +++ b/public/app/features/search/components/ConfirmDeleteModal.tsx @@ -32,7 +32,7 @@ export const ConfirmDeleteModal: FC = ({ results, onDeleteItems, isOpen, text += `selected folder${folderEnding} and dashboard${dashEnding}?\n`; subtitle = `All dashboards and alerts of the selected folder${folderEnding} will also be deleted`; } else if (folderCount > 0) { - text += `selected folder${folderEnding} and all their dashboards and alerts?`; + text += `selected folder${folderEnding} and all ${folderCount === 1 ? 'its' : 'their'} dashboards and alerts?`; } else { text += `selected dashboard${dashEnding}?`; } diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx index 57743ea0547ff..6cf87e9a9502f 100644 --- a/public/app/features/search/components/SearchItem.tsx +++ b/public/app/features/search/components/SearchItem.tsx @@ -29,6 +29,8 @@ export const SearchItem: FC = ({ item, editable, onToggleChecked, onTagSe const styles = useStyles2(getStyles); const tagSelected = useCallback( (tag: string, event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); onTagSelected(tag); }, [onTagSelected] diff --git a/public/app/features/search/hooks/useManageDashboards.test.ts b/public/app/features/search/hooks/useManageDashboards.test.ts index 6f34fdb7645f4..c3661e9945971 100644 --- a/public/app/features/search/hooks/useManageDashboards.test.ts +++ b/public/app/features/search/hooks/useManageDashboards.test.ts @@ -197,4 +197,26 @@ describe('useManageDashboards', () => { expect(result.current.canDelete).toBe(false); }); }); + + describe('when called on an empty folder', () => { + it('then canDelete should be true', () => { + const results: DashboardSection[] = [ + { id: 1, checked: true, items: [], title: 'One', type: DashboardSearchItemType.DashFolder, toggle, url: '/' }, + { + id: GENERAL_FOLDER_ID, + checked: false, + items: [], + title: 'General', + type: DashboardSearchItemType.DashFolder, + toggle, + url: '/', + }, + { id: 2, checked: false, items: [], title: 'Two', type: DashboardSearchItemType.DashFolder, toggle, url: '/' }, + ]; + + const { result } = setupTestContext({ results }); + + expect(result.current.canDelete).toBe(true); + }); + }); }); diff --git a/public/app/features/search/hooks/useManageDashboards.ts b/public/app/features/search/hooks/useManageDashboards.ts index e5512b8b53f04..82d3bae4e02e6 100644 --- a/public/app/features/search/hooks/useManageDashboards.ts +++ b/public/app/features/search/hooks/useManageDashboards.ts @@ -1,12 +1,16 @@ import { useCallback, useMemo, useReducer } from 'react'; import { FolderDTO } from 'app/types'; import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardQuery, OnDeleteItems, OnMoveItems, OnToggleChecked } from '../types'; +import { DashboardQuery, DashboardSection, OnDeleteItems, OnMoveItems, OnToggleChecked } from '../types'; import { DELETE_ITEMS, MOVE_ITEMS, TOGGLE_ALL_CHECKED, TOGGLE_CHECKED } from '../reducers/actionTypes'; import { manageDashboardsReducer, manageDashboardsState, ManageDashboardsState } from '../reducers/manageDashboards'; import { useSearch } from './useSearch'; import { GENERAL_FOLDER_ID } from '../constants'; +const hasChecked = (section: DashboardSection) => { + return section.checked || section.items.some((item) => item.checked); +}; + export const useManageDashboards = ( query: DashboardQuery, state: Partial = {}, @@ -42,14 +46,13 @@ export const useManageDashboards = ( dispatch({ type: MOVE_ITEMS, payload: { dashboards: selectedDashboards, folder } }); }; - const canMove = useMemo(() => results.some((result) => result.items && result.items.some((item) => item.checked)), [ - results, - ]); + const canMove = useMemo(() => results.some((result) => result.items.some((item) => item.checked)), [results]); const canDelete = useMemo(() => { + const somethingChecked = results.some(hasChecked); const includesGeneralFolder = results.find((result) => result.checked && result.id === GENERAL_FOLDER_ID); - return canMove && !includesGeneralFolder; - }, [canMove, results]); + return somethingChecked && !includesGeneralFolder; + }, [results]); const canSave = folder?.canSave; const hasEditPermissionInFolders = folder ? canSave : contextSrv.hasEditPermissionInFolders; diff --git a/public/app/features/templating/formatRegistry.ts b/public/app/features/templating/formatRegistry.ts index a6f11e7d9eb9d..1af3c082143ed 100644 --- a/public/app/features/templating/formatRegistry.ts +++ b/public/app/features/templating/formatRegistry.ts @@ -3,8 +3,6 @@ import { dateTime, Registry, RegistryItem, textUtil, VariableModel } from '@graf import { isArray, map, replace } from 'lodash'; import { formatVariableLabel } from '../variables/shared/formatVariable'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/state/types'; -import { variableAdapters } from '../variables/adapters'; -import { VariableModel as ExtendedVariableModel } from '../variables/types'; export interface FormatOptions { value: any; @@ -244,15 +242,14 @@ export const formatRegistry = new Registry(() => { description: 'Format variables as URL parameters. Example in multi-variable scenario A + B + C => var-foo=A&var-foo=B&var-foo=C.', formatter: (options, variable) => { - const { name, type } = variable; - const adapter = variableAdapters.get(type); - const valueForUrl = adapter.getValueForUrl(variable as ExtendedVariableModel); + const { value } = options; + const { name } = variable; - if (Array.isArray(valueForUrl)) { - return valueForUrl.map((v) => formatQueryParameter(name, v)).join('&'); + if (Array.isArray(value)) { + return value.map((v) => formatQueryParameter(name, v)).join('&'); } - return formatQueryParameter(name, valueForUrl); + return formatQueryParameter(name, value); }, }, ]; diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index a63572175a1f3..61a1253e4a9ab 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -752,29 +752,59 @@ describe('templateSrv', () => { expect(target).toBe('var-single=value1'); }); + it('query variable with single value with queryparam format and scoped vars should return correct queryparam', () => { + const target = _templateSrv.replace(`\${single:queryparam}`, { single: { value: 'value1', text: 'value1' } }); + expect(target).toBe('var-single=value1'); + }); + it('query variable with single value and queryparam format should return correct queryparam', () => { const target = _templateSrv.replace('${single}', {}, 'queryparam'); expect(target).toBe('var-single=value1'); }); + it('query variable with single value and queryparam format and scoped vars should return correct queryparam', () => { + const target = _templateSrv.replace('${single}', { single: { value: 'value1', text: 'value1' } }, 'queryparam'); + expect(target).toBe('var-single=value1'); + }); + it('query variable with multi value with queryparam format should return correct queryparam', () => { const target = _templateSrv.replace(`\${multi:queryparam}`, {}); expect(target).toBe('var-multi=value1&var-multi=value2'); }); + it('query variable with multi value with queryparam format and scoped vars should return correct queryparam', () => { + const target = _templateSrv.replace(`\${multi:queryparam}`, { multi: { value: 'value2', text: 'value2' } }); + expect(target).toBe('var-multi=value2'); + }); + it('query variable with multi value and queryparam format should return correct queryparam', () => { const target = _templateSrv.replace('${multi}', {}, 'queryparam'); expect(target).toBe('var-multi=value1&var-multi=value2'); }); + it('query variable with multi value and queryparam format and scoped vars should return correct queryparam', () => { + const target = _templateSrv.replace('${multi}', { multi: { value: 'value2', text: 'value2' } }, 'queryparam'); + expect(target).toBe('var-multi=value2'); + }); + it('query variable with adhoc value with queryparam format should return correct queryparam', () => { const target = _templateSrv.replace(`\${adhoc:queryparam}`, {}); expect(target).toBe('var-adhoc=alertstate%7C%3D%7Cfiring&var-adhoc=alertname%7C%3D%7CExampleAlertAlwaysFiring'); }); - it('query variable with multi value and queryparam format should return correct queryparam', () => { + it('query variable with adhoc value with queryparam format should return correct queryparam', () => { + const target = _templateSrv.replace(`\${adhoc:queryparam}`, { adhoc: { value: 'value2', text: 'value2' } }); + expect(target).toBe('var-adhoc=value2'); + }); + + it('query variable with adhoc value and queryparam format should return correct queryparam', () => { const target = _templateSrv.replace('${adhoc}', {}, 'queryparam'); expect(target).toBe('var-adhoc=alertstate%7C%3D%7Cfiring&var-adhoc=alertname%7C%3D%7CExampleAlertAlwaysFiring'); }); + + it('query variable with adhoc value and queryparam format should return correct queryparam', () => { + const target = _templateSrv.replace('${adhoc}', { adhoc: { value: 'value2', text: 'value2' } }, 'queryparam'); + expect(target).toBe('var-adhoc=value2'); + }); }); }); diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 72ece2d0f76db..9b522dbbf9f2d 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -7,7 +7,7 @@ import { AdHocVariableFilter, AdHocVariableModel, VariableModel } from '../varia import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime'; import { FormatOptions, formatRegistry, FormatRegistryID } from './formatRegistry'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/state/types'; -import { safeStringifyValue } from '../../core/utils/explore'; +import { variableAdapters } from '../variables/adapters'; interface FieldAccessorCache { [key: string]: (obj: any) => any; @@ -115,7 +115,7 @@ export class TemplateSrv implements BaseTemplateSrv { return filters; } - formatValue(value: any, format: any, variable: any, text?: string) { + formatValue(value: any, format: any, variable: any, text?: string): string { // for some scopedVars there is no variable variable = variable || {}; @@ -282,9 +282,9 @@ export class TemplateSrv implements BaseTemplateSrv { return match; } - if (isAdHoc(variable)) { - const value = safeStringifyValue(variable.filters); - const text = variable.id; + if (fmt === FormatRegistryID.queryParam || isAdHoc(variable)) { + const value = variableAdapters.get(variable.type).getValueForUrl(variable); + const text = isAdHoc(variable) ? variable.id : variable.current.text; return this.formatValue(value, fmt, variable, text); } @@ -301,7 +301,7 @@ export class TemplateSrv implements BaseTemplateSrv { value = this.getAllValue(variable); text = ALL_VARIABLE_TEXT; // skip formatting of custom all values - if (variable.allValue && fmt !== FormatRegistryID.text && fmt !== FormatRegistryID.queryParam) { + if (variable.allValue && fmt !== FormatRegistryID.text) { return this.replace(value); } } diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx index d22c12b3a579d..ea04bb7cbdb8c 100644 --- a/public/app/features/users/UsersActionBar.test.tsx +++ b/public/app/features/users/UsersActionBar.test.tsx @@ -7,6 +7,7 @@ import { setUsersSearchQuery } from './state/reducers'; jest.mock('app/core/core', () => ({ contextSrv: { hasPermission: () => true, + hasAccess: () => true, }, })); diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 53bc6ccc08c4c..7e50521862261 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -33,7 +33,7 @@ export class UsersActionBar extends PureComponent { { label: 'Users', value: 'users' }, { label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' }, ]; - const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd); + const canAddToOrg = contextSrv.hasAccess(AccessControlAction.UsersCreate, canInvite); return (
    @@ -49,7 +49,7 @@ export class UsersActionBar extends PureComponent {
    )} - {canInvite && canAddToOrg && Invite} + {canAddToOrg && Invite} {externalUserMngLinkUrl && ( {externalUserMngLinkName} diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx index 8bdd8a78c139d..92784d7023429 100644 --- a/public/app/features/users/UsersTable.tsx +++ b/public/app/features/users/UsersTable.tsx @@ -8,12 +8,13 @@ import { fetchBuiltinRoles, fetchRoleOptions, UserRolePicker } from 'app/core/co export interface Props { users: OrgUser[]; + orgId?: number; onRoleChange: (role: OrgRole, user: OrgUser) => void; onRemoveUser: (user: OrgUser) => void; } const UsersTable: FC = (props) => { - const { users, onRoleChange, onRemoveUser } = props; + const { users, orgId, onRoleChange, onRemoveUser } = props; const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate); const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove); const rolePickerDisabled = !canUpdateRole; @@ -25,9 +26,9 @@ const UsersTable: FC = (props) => { useEffect(() => { async function fetchOptions() { try { - let options = await fetchRoleOptions(); + let options = await fetchRoleOptions(orgId); setRoleOptions(options); - const builtInRoles = await fetchBuiltinRoles(); + const builtInRoles = await fetchBuiltinRoles(orgId); setBuiltinRoles(builtInRoles); } catch (e) { console.error('Error loading options'); @@ -36,7 +37,7 @@ const UsersTable: FC = (props) => { if (contextSrv.accessControlEnabled()) { fetchOptions(); } - }, []); + }, [orgId]); const getRoleOptions = async () => roleOptions; const getBuiltinRoles = async () => builtinRoles; @@ -83,6 +84,7 @@ const UsersTable: FC = (props) => { {contextSrv.accessControlEnabled() ? ( onRoleChange(newRole, user)} getRoleOptions={getRoleOptions} diff --git a/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap b/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap index 575a2ef0e61e1..130561cec2c37 100644 --- a/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap +++ b/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap @@ -13,6 +13,11 @@ exports[`Render should render component 1`] = ` value="" />
    + + Invite + `; @@ -53,6 +58,11 @@ exports[`Render should render pending invites button 1`] = ` value="users" /> + + Invite + `; @@ -69,6 +79,11 @@ exports[`Render should show external user management button 1`] = ` value="" /> + + Invite + { export function loadInvitees(): ThunkResult { return async (dispatch) => { - if (!contextSrv.hasPermission(AccessControlAction.OrgUsersAdd)) { + if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) { return; } diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts index 2781dfb0e1168..dd5b9151b814d 100644 --- a/public/app/features/variables/adhoc/actions.ts +++ b/public/app/features/variables/adhoc/actions.ts @@ -111,7 +111,7 @@ export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResul }; export const initAdHocVariableEditor = (): ThunkResult => (dispatch) => { - const dataSources = getDatasourceSrv().getList({ metrics: true, variables: false }); + const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); const selectable = dataSources.reduce( (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { if (ds.meta.mixed) { diff --git a/public/app/features/variables/adhoc/adapter.ts b/public/app/features/variables/adhoc/adapter.ts index 4ce13f783765a..10adfce70b762 100644 --- a/public/app/features/variables/adhoc/adapter.ts +++ b/public/app/features/variables/adhoc/adapter.ts @@ -1,6 +1,4 @@ import { cloneDeep } from 'lodash'; -import { getDataSourceSrv } from '@grafana/runtime'; -import { getDataSourceRef } from '@grafana/data'; import { AdHocVariableModel } from '../types'; import { dispatch } from '../../../store/store'; @@ -10,7 +8,6 @@ import { adHocVariableReducer, initialAdHocVariableModelState } from './reducer' import { AdHocVariableEditor } from './AdHocVariableEditor'; import { setFiltersFromUrl } from './actions'; import * as urlParser from './urlParser'; -import { isAdHoc, isLegacyAdHocDataSource } from '../guard'; const noop = async () => {}; @@ -38,24 +35,5 @@ export const createAdHocVariableAdapter = (): VariableAdapter { - if (!isAdHoc(model)) { - return model; - } - - if (!isLegacyAdHocDataSource(model.datasource)) { - return model; - } - - const ds = getDataSourceSrv().getInstanceSettings(model.datasource); - if (!ds) { - return model; - } - - const clone = cloneDeep(model); - clone.datasource = getDataSourceRef(ds); - - return { ...clone }; - }, }; }; diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 2ebafabb99b76..047ba76059e1b 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -17,10 +17,7 @@ const mapStateToProps = (state: StoreState) => ({ variables: getEditorVariables(state), idInEditor: state.templating.editor.id, dashboard: state.dashboard.getModel(), - unknownsNetwork: state.templating.inspect.unknownsNetwork, - unknownExists: state.templating.inspect.unknownExits, usagesNetwork: state.templating.inspect.usagesNetwork, - unknown: state.templating.inspect.unknown, usages: state.templating.inspect.usages, }); @@ -119,7 +116,7 @@ class VariableEditorContainerUnconnected extends PureComponent { usages={this.props.usages} usagesNetwork={this.props.usagesNetwork} /> - {this.props.unknownExists ? : null} + )} {variableToEdit && } diff --git a/public/app/features/variables/editor/actions.ts b/public/app/features/variables/editor/actions.ts index ad922516815bc..67c5ae91227f5 100644 --- a/public/app/features/variables/editor/actions.ts +++ b/public/app/features/variables/editor/actions.ts @@ -109,12 +109,10 @@ export const switchToListMode = (): ThunkResult => (dispatch, getState) => const state = getState(); const variables = getEditorVariables(state); const dashboard = state.dashboard.getModel(); - const { unknown, usages } = createUsagesNetwork(variables, dashboard); - const unknownsNetwork = transformUsagesToNetwork(unknown); - const unknownExits = Object.keys(unknown).length > 0; + const { usages } = createUsagesNetwork(variables, dashboard); const usagesNetwork = transformUsagesToNetwork(usages); - dispatch(initInspect({ unknown, usages, usagesNetwork, unknownsNetwork, unknownExits })); + dispatch(initInspect({ usages, usagesNetwork })); }; export function getNextAvailableId(type: VariableType, variables: VariableModel[]): string { diff --git a/public/app/features/variables/inspect/VariablesUnknownButton.tsx b/public/app/features/variables/inspect/VariablesUnknownButton.tsx index 596d40268c0dc..a8ec1998118d0 100644 --- a/public/app/features/variables/inspect/VariablesUnknownButton.tsx +++ b/public/app/features/variables/inspect/VariablesUnknownButton.tsx @@ -25,7 +25,14 @@ export const VariablesUnknownButton: FC = ({ id, usages }) => { return ( {({ showModal }) => { - return showModal()} name="code-branch" title="Show usages" />; + return ( + showModal()} + name="code-branch" + title="Show usages" + data-testid="VariablesUnknownButton" + /> + ); }} ); diff --git a/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx b/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx new file mode 100644 index 0000000000000..7fb888ca5395b --- /dev/null +++ b/public/app/features/variables/inspect/VariablesUnknownTable.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import * as runtime from '@grafana/runtime'; +import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { VariablesUnknownTable, VariablesUnknownTableProps } from './VariablesUnknownTable'; +import { customBuilder } from '../shared/testing/builders'; +import * as utils from './utils'; +import { UsagesToNetwork } from './utils'; + +async function getTestContext( + overrides: Partial | undefined = {}, + usages: UsagesToNetwork[] = [] +) { + jest.clearAllMocks(); + const reportInteractionSpy = jest.spyOn(runtime, 'reportInteraction').mockImplementation(); + const getUnknownsNetworkSpy = jest.spyOn(utils, 'getUnknownsNetwork').mockResolvedValue(usages); + const defaults: VariablesUnknownTableProps = { + variables: [], + dashboard: null, + }; + const props = { ...defaults, ...overrides }; + const { rerender } = render(); + await waitFor(() => + expect(screen.getByRole('heading', { name: /renamed or missing variables/i })).toBeInTheDocument() + ); + + return { reportInteractionSpy, getUnknownsNetworkSpy, rerender }; +} + +describe('VariablesUnknownTable', () => { + describe('when rendered', () => { + it('then it should render the section header', async () => { + await getTestContext(); + }); + }); + + describe('when expanding the section', () => { + it('then it should show loading spinner', async () => { + await getTestContext(); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument()); + }); + + it('then it should call getUnknownsNetwork', async () => { + const { getUnknownsNetworkSpy } = await getTestContext(); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1)); + }); + + it('then it should report the interaction', async () => { + const { reportInteractionSpy } = await getTestContext(); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument()); + + expect(reportInteractionSpy).toHaveBeenCalledTimes(1); + expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded'); + }); + + describe('but when expanding it again without changes to variables or dashboard', () => { + it('then it should not call getUnknownsNetwork', async () => { + const { getUnknownsNetworkSpy } = await getTestContext(); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByTitle('Click to collapse')).toBeInTheDocument()); + expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByTitle('Click to expand')).toBeInTheDocument()); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitFor(() => expect(screen.getByTitle('Click to collapse')).toBeInTheDocument()); + + expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('and there are no renamed or missing variables', () => { + it('then it should render the correct message', async () => { + await getTestContext(); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitForElementToBeRemoved(() => screen.getByText('Loading...')); + + expect(screen.getByText('No renamed or missing variables found.')).toBeInTheDocument(); + }); + }); + + describe('and there are renamed or missing variables', () => { + it('then it should render the table', async () => { + const variable = customBuilder().withId('Renamed Variable').withName('Renamed Variable').build(); + const usages = [{ variable, nodes: [], edges: [], showGraph: false }]; + const { reportInteractionSpy } = await getTestContext({}, usages); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitForElementToBeRemoved(() => screen.getByText('Loading...')); + + expect(screen.queryByText('No renamed or missing variables found.')).not.toBeInTheDocument(); + expect(screen.getByText('Renamed Variable')).toBeInTheDocument(); + expect(screen.getAllByTestId('VariablesUnknownButton')).toHaveLength(1); + + // make sure we don't report the interaction for slow expansion + expect(reportInteractionSpy).toHaveBeenCalledTimes(1); + expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded'); + }); + + describe('but when the unknown processing takes a while', () => { + const origDateNow = Date.now; + + afterEach(() => { + Date.now = origDateNow; + }); + + it('then it should report slow expansion', async () => { + const variable = customBuilder().withId('Renamed Variable').withName('Renamed Variable').build(); + const usages = [{ variable, nodes: [], edges: [], showGraph: false }]; + const { reportInteractionSpy } = await getTestContext({}, usages); + const dateNowStart = 1000; + const dateNowStop = 2000; + Date.now = jest.fn().mockReturnValueOnce(dateNowStart).mockReturnValue(dateNowStop); + + userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i })); + await waitForElementToBeRemoved(() => screen.getByText('Loading...')); + + // make sure we report the interaction for slow expansion + expect(reportInteractionSpy).toHaveBeenCalledTimes(2); + expect(reportInteractionSpy.mock.calls[0][0]).toEqual('Unknown variables section expanded'); + expect(reportInteractionSpy.mock.calls[1][0]).toEqual('Slow unknown variables expansion'); + expect(reportInteractionSpy.mock.calls[1][1]).toEqual({ elapsed: 1000 }); + }); + }); + }); + }); +}); diff --git a/public/app/features/variables/inspect/VariablesUnknownTable.tsx b/public/app/features/variables/inspect/VariablesUnknownTable.tsx index 7a2435c81df3e..5c45b6a5b889c 100644 --- a/public/app/features/variables/inspect/VariablesUnknownTable.tsx +++ b/public/app/features/variables/inspect/VariablesUnknownTable.tsx @@ -1,63 +1,129 @@ -import React, { FC } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import { css } from '@emotion/css'; -import { Icon, Tooltip, useStyles } from '@grafana/ui'; +import { useAsync } from 'react-use'; +import { CollapsableSection, HorizontalGroup, Icon, Spinner, Tooltip, useStyles, VerticalGroup } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; -import { UsagesToNetwork } from './utils'; +import { reportInteraction } from '@grafana/runtime'; + +import { VariableModel } from '../types'; +import { DashboardModel } from '../../dashboard/state'; import { VariablesUnknownButton } from './VariablesUnknownButton'; +import { getUnknownsNetwork, UsagesToNetwork } from './utils'; + +export const SLOW_VARIABLES_EXPANSION_THRESHOLD = 1000; -interface Props { - usages: UsagesToNetwork[]; +export interface VariablesUnknownTableProps { + variables: VariableModel[]; + dashboard: DashboardModel | null; } -export const VariablesUnknownTable: FC = ({ usages }) => { +export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknownTableProps): ReactElement { + const [open, setOpen] = useState(false); + const [changed, setChanged] = useState(0); + const [usages, setUsages] = useState([]); const style = useStyles(getStyles); + useEffect(() => setChanged((prevState) => prevState + 1), [variables, dashboard]); + const { loading } = useAsync(async () => { + if (open && changed > 0) { + // make sure we only fetch when opened and variables or dashboard have changed + const start = Date.now(); + const unknownsNetwork = await getUnknownsNetwork(variables, dashboard); + const stop = Date.now(); + const elapsed = stop - start; + if (elapsed >= SLOW_VARIABLES_EXPANSION_THRESHOLD) { + reportInteraction('Slow unknown variables expansion', { elapsed }); + } + setChanged(0); + setUsages(unknownsNetwork); + return unknownsNetwork; + } + + return []; + }, [variables, dashboard, open, changed]); + + const onToggle = (isOpen: boolean) => { + if (isOpen) { + reportInteraction('Unknown variables section expanded'); + } + + setOpen(isOpen); + }; + return (
    -
    - Unknown Variables - - - -
    + } isOpen={open} onToggle={onToggle}> + {loading && ( + + + Loading... + + + + )} + {!loading && usages && ( + <> + {usages.length === 0 && } + {usages.length > 0 && } + + )} + +
    + ); +} + +function CollapseLabel(): ReactElement { + const style = useStyles(getStyles); + return ( +
    + Renamed or missing variables + + + +
    + ); +} + +function NoUnknowns(): ReactElement { + return No renamed or missing variables found.; +} -
    -
    - - - -
    Variable +function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement { + const style = useStyles(getStyles); + return ( + + + + + + + + {usages.map((usage) => { + const { variable } = usage; + const { id, name } = variable; + return ( + + + - - - {usages.map((usage) => { - const { variable } = usage; - const { id, name } = variable; - return ( - - - - - ); - })} - -
    Variable +
    + {name} + + + + + +
    - {name} - - - - - -
    - - + ); + })} + +
    ); -}; +} const getStyles = (theme: GrafanaTheme) => ({ container: css` margin-top: ${theme.spacing.xl}; padding-top: ${theme.spacing.xl}; - border-top: 1px solid ${theme.colors.panelBorder}; `, infoIcon: css` margin-left: ${theme.spacing.sm}; diff --git a/public/app/features/variables/inspect/reducer.test.ts b/public/app/features/variables/inspect/reducer.test.ts index e7d8c763d1a6d..75218bcb36666 100644 --- a/public/app/features/variables/inspect/reducer.test.ts +++ b/public/app/features/variables/inspect/reducer.test.ts @@ -10,19 +10,13 @@ describe('variableInspectReducer', () => { .givenReducer(variableInspectReducer, { ...initialVariableInspectState }) .whenActionIsDispatched( initInspect({ - unknownExits: true, - unknownsNetwork: [{ edges: [], nodes: [], showGraph: true, variable }], usagesNetwork: [{ edges: [], nodes: [], showGraph: true, variable }], usages: [{ variable, tree: {} }], - unknown: [{ variable, tree: {} }], }) ) .thenStateShouldEqual({ - unknownExits: true, - unknownsNetwork: [{ edges: [], nodes: [], showGraph: true, variable }], usagesNetwork: [{ edges: [], nodes: [], showGraph: true, variable }], usages: [{ variable, tree: {} }], - unknown: [{ variable, tree: {} }], }); }); }); diff --git a/public/app/features/variables/inspect/reducer.ts b/public/app/features/variables/inspect/reducer.ts index e1101727db8d5..8ec775d4d1a5e 100644 --- a/public/app/features/variables/inspect/reducer.ts +++ b/public/app/features/variables/inspect/reducer.ts @@ -2,40 +2,22 @@ import { UsagesToNetwork, VariableUsageTree } from './utils'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export interface VariableInspectState { - unknown: VariableUsageTree[]; usages: VariableUsageTree[]; - unknownsNetwork: UsagesToNetwork[]; usagesNetwork: UsagesToNetwork[]; - unknownExits: boolean; } export const initialVariableInspectState: VariableInspectState = { - unknown: [], usages: [], - unknownsNetwork: [], usagesNetwork: [], - unknownExits: false, }; const variableInspectReducerSlice = createSlice({ name: 'templating/inspect', initialState: initialVariableInspectState, reducers: { - initInspect: ( - state, - action: PayloadAction<{ - unknown: VariableUsageTree[]; - usages: VariableUsageTree[]; - unknownsNetwork: UsagesToNetwork[]; - usagesNetwork: UsagesToNetwork[]; - unknownExits: boolean; - }> - ) => { - const { unknown, usages, unknownExits, unknownsNetwork, usagesNetwork } = action.payload; + initInspect: (state, action: PayloadAction<{ usages: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[] }>) => { + const { usages, usagesNetwork } = action.payload; state.usages = usages; - state.unknown = unknown; - state.unknownsNetwork = unknownsNetwork; - state.unknownExits = unknownExits; state.usagesNetwork = usagesNetwork; }, }, diff --git a/public/app/features/variables/inspect/utils.ts b/public/app/features/variables/inspect/utils.ts index 35f7785501608..45943f15c4f4c 100644 --- a/public/app/features/variables/inspect/utils.ts +++ b/public/app/features/variables/inspect/utils.ts @@ -179,29 +179,18 @@ export interface VariableUsageTree { export interface VariableUsages { unUsed: VariableModel[]; - unknown: VariableUsageTree[]; usages: VariableUsageTree[]; } export const createUsagesNetwork = (variables: VariableModel[], dashboard: DashboardModel | null): VariableUsages => { if (!dashboard) { - return { unUsed: [], unknown: [], usages: [] }; + return { unUsed: [], usages: [] }; } const unUsed: VariableModel[] = []; let usages: VariableUsageTree[] = []; - let unknown: VariableUsageTree[] = []; const model = dashboard.getSaveModelClone(); - const unknownVariables = getUnknownVariableStrings(variables, model); - for (const unknownVariable of unknownVariables) { - const props = getPropsWithVariable(unknownVariable, { key: 'model', value: model }, {}); - if (Object.keys(props).length) { - const variable = ({ id: unknownVariable, name: unknownVariable } as unknown) as VariableModel; - unknown.push({ variable, tree: props }); - } - } - for (const variable of variables) { const variableId = variable.id; const props = getPropsWithVariable(variableId, { key: 'model', value: model }, {}); @@ -214,9 +203,46 @@ export const createUsagesNetwork = (variables: VariableModel[], dashboard: Dashb } } - return { unUsed, unknown, usages }; + return { unUsed, usages }; }; +export async function getUnknownsNetwork( + variables: VariableModel[], + dashboard: DashboardModel | null +): Promise { + return new Promise((resolve, reject) => { + // can be an expensive call so we avoid blocking the main thread + setTimeout(() => { + try { + const unknowns = createUnknownsNetwork(variables, dashboard); + resolve(transformUsagesToNetwork(unknowns)); + } catch (e) { + reject(e); + } + }, 200); + }); +} + +function createUnknownsNetwork(variables: VariableModel[], dashboard: DashboardModel | null): VariableUsageTree[] { + if (!dashboard) { + return []; + } + + let unknown: VariableUsageTree[] = []; + const model = dashboard.getSaveModelClone(); + + const unknownVariables = getUnknownVariableStrings(variables, model); + for (const unknownVariable of unknownVariables) { + const props = getPropsWithVariable(unknownVariable, { key: 'model', value: model }, {}); + if (Object.keys(props).length) { + const variable = ({ id: unknownVariable, name: unknownVariable } as unknown) as VariableModel; + unknown.push({ variable, tree: props }); + } + } + + return unknown; +} + /* getAllAffectedPanelIdsForVariableChange is a function that extracts all the panel ids that are affected by a single variable change. It will traverse all chained variables to identify all cascading changes too. diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index 242ca54036f37..b5467d5681544 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -18,6 +18,8 @@ import { intervalBuilder } from '../shared/testing/builders'; import { updateOptions } from '../state/actions'; import { notifyApp } from '../../../core/actions'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; +import { variablesInitTransaction } from '../state/transactionReducer'; +import { afterEach, beforeEach } from '../../../../test/lib/common'; describe('interval actions', () => { variableAdapters.setInit(() => [createIntervalVariableAdapter()]); @@ -43,7 +45,8 @@ describe('interval actions', () => { describe('when updateOptions is dispatched but something throws', () => { silenceConsoleOutput(); - it('then an notifyApp action should be dispatched', async () => { + const originalTimeSrv = getTimeSrv(); + beforeEach(() => { const timeSrvMock = ({ timeRange: jest.fn().mockReturnValue({ from: dateTime(new Date()).subtract(1, 'days').toDate(), @@ -54,8 +57,14 @@ describe('interval actions', () => { }, }), } as unknown) as TimeSrv; - const originalTimeSrv = getTimeSrv(); setTimeSrv(timeSrvMock); + }); + + afterEach(() => { + setTimeSrv(originalTimeSrv); + }); + + it('then an notifyApp action should be dispatched', async () => { const interval = intervalBuilder() .withId('0') .withQuery('1s,1m,1h,1d') @@ -66,6 +75,7 @@ describe('interval actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { @@ -91,8 +101,26 @@ describe('interval actions', () => { return dispatchedActions.length === expectedNumberOfActions; }); + }); - setTimeSrv(originalTimeSrv); + describe('but there is no ongoing transaction', () => { + it('then no actions are dispatched', async () => { + const interval = intervalBuilder() + .withId('0') + .withQuery('1s,1m,1h,1d') + .withAuto(true) + .withAutoMin('1xyz') // illegal interval string + .build(); + + const tester = await reduxTester() + .givenRootReducer(getRootReducer()) + .whenActionIsDispatched( + addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + ) + .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); + + tester.thenNoActionsWhereDispatched(); + }); }); }); diff --git a/public/app/features/variables/query/VariableQueryRunner.ts b/public/app/features/variables/query/VariableQueryRunner.ts index 597ff44ad0474..ea17c2ce2ea2a 100644 --- a/public/app/features/variables/query/VariableQueryRunner.ts +++ b/public/app/features/variables/query/VariableQueryRunner.ts @@ -1,5 +1,5 @@ import { merge, Observable, of, Subject, throwError, Unsubscribable } from 'rxjs'; -import { catchError, filter, finalize, first, mergeMap, takeUntil } from 'rxjs/operators'; +import { catchError, filter, finalize, mergeMap, take, takeUntil } from 'rxjs/operators'; import { CoreApp, DataQuery, @@ -7,6 +7,7 @@ import { DataSourceApi, getDefaultTimeRange, LoadingState, + PanelData, ScopedVars, } from '@grafana/data'; @@ -115,12 +116,14 @@ export class VariableQueryRunner { filter(() => { // Lets check if we started another batch during the execution of the observable. If so we just want to abort the rest. const afterUid = getState().templating.transaction.uid; + return beforeUid === afterUid; }), - first((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), - mergeMap((data) => { + filter((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), // we only care about done or error for now + take(1), // take the first result, using first caused a bug where it in some situations throw an uncaught error because of no results had been received yet + mergeMap((data: PanelData) => { if (data.state === LoadingState.Error) { - return throwError(data.error); + return throwError(() => data.error); } return of(data); @@ -148,7 +151,7 @@ export class VariableQueryRunner { } this.updateOptionsResults.next({ identifier, state: LoadingState.Error, error }); - return throwError(error); + return throwError(() => error); }), finalize(() => { this.updateOptionsResults.next({ identifier, state: LoadingState.Done }); diff --git a/public/app/features/variables/query/actions.test.ts b/public/app/features/variables/query/actions.test.ts index 9f2c07270fb62..7dd7c70b9a5e3 100644 --- a/public/app/features/variables/query/actions.test.ts +++ b/public/app/features/variables/query/actions.test.ts @@ -37,6 +37,7 @@ import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsole import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { setVariableQueryRunner, VariableQueryRunner } from './VariableQueryRunner'; import { setDataSourceSrv } from '@grafana/runtime'; +import { variablesInitTransaction } from '../state/transactionReducer'; const mocks: Record = { datasource: { @@ -78,6 +79,22 @@ describe('query actions', () => { variableAdapters.setInit(() => [createQueryVariableAdapter()]); + describe('when updateQueryVariableOptions is dispatched but there is no ongoing transaction', () => { + it('then correct actions are dispatched', async () => { + const variable = createVariable({ includeAll: false }); + const optionsMetrics = [createMetric('A'), createMetric('B')]; + + mockDatasourceMetrics(variable, optionsMetrics); + + const tester = await reduxTester() + .givenRootReducer(getRootReducer()) + .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + + tester.thenNoActionsWhereDispatched(); + }); + }); + describe('when updateQueryVariableOptions is dispatched for variable without both tags and includeAll', () => { it('then correct actions are dispatched', async () => { const variable = createVariable({ includeAll: false }); @@ -88,6 +105,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); const option = createOption('A'); @@ -110,6 +128,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); @@ -136,6 +155,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); @@ -164,6 +184,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable), 'search'), true); @@ -191,6 +212,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenActionIsDispatched(setIdInEditor({ id: variable.id })) .whenAsyncActionIsDispatched(updateOptions(toVariablePayload(variable)), true); @@ -227,6 +249,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((actions) => { @@ -256,6 +279,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((actions) => { @@ -284,6 +308,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((actions) => { @@ -306,6 +331,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((actions) => { @@ -330,6 +356,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched( changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), true @@ -365,6 +392,7 @@ describe('query actions', () => { .whenActionIsDispatched( addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })) ) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched( changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), true @@ -402,6 +430,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched( changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), true @@ -436,6 +465,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); @@ -466,6 +496,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); @@ -495,6 +526,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); const option = createOption('A'); @@ -521,6 +553,7 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); const errorText = 'Query cannot contain a reference to itself. Variable: $' + variable.name; diff --git a/public/app/features/variables/query/actions.ts b/public/app/features/variables/query/actions.ts index 830e543c11a93..eef29c59594c6 100644 --- a/public/app/features/variables/query/actions.ts +++ b/public/app/features/variables/query/actions.ts @@ -18,14 +18,20 @@ import { getVariableQueryEditor } from '../editor/getVariableQueryEditor'; import { getVariableQueryRunner } from './VariableQueryRunner'; import { variableQueryObserver } from './variableQueryObserver'; import { QueryVariableEditorState } from './reducer'; +import { hasOngoingTransaction } from '../utils'; export const updateQueryVariableOptions = ( identifier: VariableIdentifier, searchFilter?: string ): ThunkResult => { return async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); try { + if (!hasOngoingTransaction(getState())) { + // we might have cancelled a batch so then variable state is removed + return; + } + + const variableInState = getVariable(identifier.id, getState()); if (getState().templating.editor.id === variableInState.id) { dispatch(removeVariableEditorError({ errorProp: 'update' })); } @@ -43,7 +49,7 @@ export const updateQueryVariableOptions = ( }); } catch (err) { const error = toDataQueryError(err); - if (getState().templating.editor.id === variableInState.id) { + if (getState().templating.editor.id === identifier.id) { dispatch(addVariableEditorError({ errorProp: 'update', errorText: error.message })); } diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index 4b1713238a1b9..ca82e3219e304 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,6 +1,6 @@ import { AnyAction } from 'redux'; -import { getRootReducer, getTemplatingRootReducer, RootReducerType, TemplatingReducerType } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; @@ -14,7 +14,6 @@ import { cleanUpVariables, fixSelectedInconsistency, initDashboardTemplating, - initVariablesTransaction, isVariableUrlValueDifferentFromCurrent, processVariables, validateVariableSelectionState, @@ -47,18 +46,11 @@ import { changeVariableNameFailed, changeVariableNameSucceeded, cleanEditorState, - initialVariableEditorState, setIdInEditor, } from '../editor/reducer'; -import { - TransactionStatus, - variablesClearTransaction, - variablesCompleteTransaction, - variablesInitTransaction, -} from './transactionReducer'; -import { cleanPickerState, initialState } from '../pickers/OptionsPicker/reducer'; +import { variablesClearTransaction, variablesInitTransaction } from './transactionReducer'; +import { cleanPickerState } from '../pickers/OptionsPicker/reducer'; import { cleanVariables } from './variablesReducer'; -import { expect } from '../../../../test/lib/common'; import { ConstantVariableModel, VariableRefresh } from '../types'; import { updateVariableOptions } from '../query/reducer'; import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner'; @@ -577,86 +569,6 @@ describe('shared actions', () => { }); }); - describe('initVariablesTransaction', () => { - function getTestContext() { - const reportSpy = jest.spyOn(runtime, 'reportInteraction').mockReturnValue(undefined); - const constant = constantBuilder().withId('constant').withName('constant').build(); - const templating: any = { list: [constant] }; - const uid = 'uid'; - const dashboard: any = { title: 'Some dash', uid, templating }; - - return { reportSpy, constant, templating, uid, dashboard }; - } - - describe('when called and the previous dashboard has completed', () => { - it('then correct actions are dispatched', async () => { - const { constant, uid, dashboard } = getTestContext(); - const tester = await reduxTester() - .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); - - tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[1].type).toEqual(addVariable.type); - expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[2].type).toEqual(addVariable.type); - expect(dispatchedActions[2].payload.id).toEqual('__org'); - expect(dispatchedActions[3].type).toEqual(addVariable.type); - expect(dispatchedActions[3].payload.id).toEqual('__user'); - expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) - ); - expect(dispatchedActions[5]).toEqual(variableStateNotStarted(toVariablePayload(constant))); - expect(dispatchedActions[6]).toEqual(variableStateCompleted(toVariablePayload(constant))); - - expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid })); - return dispatchedActions.length === 8; - }); - }); - }); - - describe('when called and the previous dashboard is still processing variables', () => { - it('then correct actions are dispatched', async () => { - const { constant, uid, dashboard } = getTestContext(); - const transactionState = { uid: 'previous-uid', status: TransactionStatus.Fetching }; - - const tester = await reduxTester({ - preloadedState: ({ - templating: { - transaction: transactionState, - variables: {}, - optionsPicker: { ...initialState }, - editor: { ...initialVariableEditorState }, - }, - } as unknown) as RootReducerType, - }) - .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); - - tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(cleanVariables()); - expect(dispatchedActions[1]).toEqual(cleanEditorState()); - expect(dispatchedActions[2]).toEqual(cleanPickerState()); - expect(dispatchedActions[3]).toEqual(variablesClearTransaction()); - expect(dispatchedActions[4]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[5].type).toEqual(addVariable.type); - expect(dispatchedActions[5].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[6].type).toEqual(addVariable.type); - expect(dispatchedActions[6].payload.id).toEqual('__org'); - expect(dispatchedActions[7].type).toEqual(addVariable.type); - expect(dispatchedActions[7].payload.id).toEqual('__user'); - expect(dispatchedActions[8]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) - ); - expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(constant))); - expect(dispatchedActions[10]).toEqual(variableStateCompleted(toVariablePayload(constant))); - expect(dispatchedActions[11]).toEqual(variablesCompleteTransaction({ uid })); - return dispatchedActions.length === 12; - }); - }); - }); - }); - describe('cleanUpVariables', () => { describe('when called', () => { it('then correct actions are dispatched', async () => { diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 392da1199714c..43f444b1182e0 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -1,6 +1,14 @@ import angular from 'angular'; import { castArray, isEqual } from 'lodash'; -import { DataQuery, LoadingState, TimeRange, UrlQueryMap, UrlQueryValue } from '@grafana/data'; +import { + DataQuery, + getDataSourceRef, + isDataSourceRef, + LoadingState, + TimeRange, + UrlQueryMap, + UrlQueryValue, +} from '@grafana/data'; import { DashboardVariableModel, @@ -13,8 +21,9 @@ import { VariableOption, VariableRefresh, VariablesChanged, + VariablesChangedEvent, VariablesChangedInUrl, - VariablesFinishedProcessingTimeRangeChange, + VariablesTimeRangeProcessDone, VariableWithMultiSupport, VariableWithOptions, } from '../types'; @@ -62,7 +71,13 @@ import { } from './transactionReducer'; import { getBackendSrv } from '../../../core/services/backend_srv'; import { cleanVariables } from './variablesReducer'; -import { ensureStringValues, ExtendedUrlQueryMap, getCurrentText, getVariableRefresh } from '../utils'; +import { + ensureStringValues, + ExtendedUrlQueryMap, + getCurrentText, + getVariableRefresh, + hasOngoingTransaction, +} from '../utils'; import { store } from 'app/store/store'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { cleanEditorState } from '../editor/reducer'; @@ -518,9 +533,9 @@ export const variableUpdated = ( const variables = getVariables(state); const g = createGraph(variables); const panels = state.dashboard?.getModel()?.panels ?? []; - const affectedPanelIds = isAdHoc(variableInState) - ? undefined // for adhoc variables we don't know which panels that will be impacted - : getAllAffectedPanelIdsForVariableChange(variableInState.id, variables, panels); + const event: VariablesChangedEvent = isAdHoc(variableInState) + ? { refreshAll: true, panelIds: [] } // for adhoc variables we don't know which panels that will be impacted + : { refreshAll: false, panelIds: getAllAffectedPanelIdsForVariableChange(variableInState.id, variables, panels) }; const node = g.getNode(variableInState.name); let promises: Array> = []; @@ -537,7 +552,7 @@ export const variableUpdated = ( return Promise.all(promises).then(() => { if (emitChangeEvents) { - events.publish(new VariablesChanged({ panelIds: affectedPanelIds })); + events.publish(new VariablesChanged(event)); locationService.partial(getQueryWithVariables(getState)); } }); @@ -563,13 +578,14 @@ export const onTimeRangeUpdated = ( return false; }) as VariableWithOptions[]; + const variableIds = variablesThatNeedRefresh.map((variable) => variable.id); const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) => dispatch(timeRangeUpdated(toVariableIdentifier(variable))) ); try { await Promise.all(promises); - dependencies.events.publish(new VariablesFinishedProcessingTimeRangeChange({ panelIds: undefined })); + dependencies.events.publish(new VariablesTimeRangeProcessDone({ variableIds })); } catch (error) { console.error(error); dispatch(notifyApp(createVariableErrorNotification('Template variable service failed', error))); @@ -625,7 +641,7 @@ export const templateVarsChangedInUrl = ( if (update.length) { await Promise.all(update); - events.publish(new VariablesChangedInUrl({ panelIds: undefined })); + events.publish(new VariablesChangedInUrl({ panelIds: [], refreshAll: true })); } }; @@ -678,6 +694,8 @@ export const initVariablesTransaction = (dashboardUid: string, dashboard: Dashbo dispatch(addSystemTemplateVariables(dashboard)); // Load all variables into redux store dispatch(initDashboardTemplating(dashboard.templating.list)); + // Migrate data source name to ref + dispatch(migrateVariablesDatasourceNameToRef()); // Process all variable updates await dispatch(processVariables()); // Mark update as complete @@ -688,6 +706,31 @@ export const initVariablesTransaction = (dashboardUid: string, dashboard: Dashbo } }; +export function migrateVariablesDatasourceNameToRef( + getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv +): ThunkResult { + return function (dispatch, getState) { + const variables = getVariables(getState()); + for (const variable of variables) { + if (!isAdHoc(variable) && !isQuery(variable)) { + continue; + } + + const { datasource: nameOrRef } = variable; + + if (isDataSourceRef(nameOrRef)) { + continue; + } + + // the call to getInstanceSettings needs to be done after initDashboardTemplating because we might have + // datasource variables that need to be resolved + const ds = getDatasourceSrvFunc().getInstanceSettings(nameOrRef); + const dsRef = !ds ? { uid: nameOrRef } : getDataSourceRef(ds); + dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: dsRef }))); + } + }; +} + export const cleanUpVariables = (): ThunkResult => (dispatch) => { dispatch(cleanVariables()); dispatch(cleanEditorState()); @@ -707,14 +750,19 @@ export const updateOptions = (identifier: VariableIdentifier, rethrow = false): dispatch, getState ) => { - const variableInState = getVariable(identifier.id, getState()); try { + if (!hasOngoingTransaction(getState())) { + // we might have cancelled a batch so then variable state is removed + return; + } + + const variableInState = getVariable(identifier.id, getState()); dispatch(variableStateFetching(toVariablePayload(variableInState))); await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState))); await variableAdapters.get(variableInState.type).updateOptions(variableInState); dispatch(completeVariableLoading(identifier)); } catch (error) { - dispatch(variableStateFailed(toVariablePayload(variableInState, { error }))); + dispatch(variableStateFailed(toVariablePayload(identifier, { error }))); if (!rethrow) { console.error(error); @@ -738,6 +786,11 @@ export const createVariableErrorNotification = ( ); export const completeVariableLoading = (identifier: VariableIdentifier): ThunkResult => (dispatch, getState) => { + if (!hasOngoingTransaction(getState())) { + // we might have cancelled a batch so then variable state is removed + return; + } + const variableInState = getVariable(identifier.id, getState()); if (variableInState.state !== LoadingState.Done) { @@ -750,6 +803,11 @@ export function upgradeLegacyQueries( getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv ): ThunkResult { return async function (dispatch, getState) { + if (!hasOngoingTransaction(getState())) { + // we might have cancelled a batch so then variable state is removed + return; + } + const variable = getVariable(identifier.id, getState()); if (!isQuery(variable)) { diff --git a/public/app/features/variables/state/initVariableTransaction.test.ts b/public/app/features/variables/state/initVariableTransaction.test.ts new file mode 100644 index 0000000000000..1420a64a307b2 --- /dev/null +++ b/public/app/features/variables/state/initVariableTransaction.test.ts @@ -0,0 +1,190 @@ +import { getRootReducer, RootReducerType } from './helpers'; +import { variableAdapters } from '../adapters'; +import { createQueryVariableAdapter } from '../query/adapter'; +import { createConstantVariableAdapter } from '../constant/adapter'; +import { reduxTester } from '../../../../test/core/redux/reduxTester'; +import { + addVariable, + changeVariableProp, + setCurrentVariableValue, + variableStateCompleted, + variableStateFetching, + variableStateNotStarted, +} from './sharedReducer'; +import { toVariablePayload } from './types'; +import { adHocBuilder, constantBuilder, datasourceBuilder, queryBuilder } from '../shared/testing/builders'; +import { cleanEditorState, initialVariableEditorState } from '../editor/reducer'; +import { + TransactionStatus, + variablesClearTransaction, + variablesCompleteTransaction, + variablesInitTransaction, +} from './transactionReducer'; +import { cleanPickerState, initialState } from '../pickers/OptionsPicker/reducer'; +import { cleanVariables } from './variablesReducer'; +import { createAdHocVariableAdapter } from '../adhoc/adapter'; +import { createDataSourceVariableAdapter } from '../datasource/adapter'; +import { DataSourceRef, LoadingState } from '@grafana/data/src'; +import { setDataSourceSrv } from '@grafana/runtime/src'; +import { VariableModel } from '../types'; +import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelpers'; +import { setVariableQueryRunner } from '../query/VariableQueryRunner'; +import { createDataSourceOptions } from '../datasource/reducer'; +import { initVariablesTransaction } from './actions'; + +variableAdapters.setInit(() => [ + createQueryVariableAdapter(), + createConstantVariableAdapter(), + createAdHocVariableAdapter(), + createDataSourceVariableAdapter(), +]); + +function getTestContext(variables?: VariableModel[]) { + const uid = 'uid'; + const constant = constantBuilder().withId('constant').withName('constant').build(); + const templating = { list: variables ?? [constant] }; + const getInstanceSettingsMock = jest.fn().mockReturnValue(undefined); + setDataSourceSrv({ + get: jest.fn().mockResolvedValue({}), + getList: jest.fn().mockReturnValue([]), + getInstanceSettings: getInstanceSettingsMock, + }); + const variableQueryRunner: any = { + cancelRequest: jest.fn(), + queueRequest: jest.fn(), + getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: { type: 'query', id: 'query' } }), + destroy: jest.fn(), + }; + setVariableQueryRunner(variableQueryRunner); + + const dashboard: any = { title: 'Some dash', uid, templating }; + + return { constant, getInstanceSettingsMock, templating, uid, dashboard }; +} + +describe('initVariablesTransaction', () => { + describe('when called and the previous dashboard has completed', () => { + it('then correct actions are dispatched', async () => { + const { constant, uid, dashboard } = getTestContext(); + const tester = await reduxTester() + .givenRootReducer(getRootReducer()) + .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + + tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { + expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); + expect(dispatchedActions[1].type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.id).toEqual('__org'); + expect(dispatchedActions[3].type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.id).toEqual('__user'); + expect(dispatchedActions[4]).toEqual( + addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + ); + expect(dispatchedActions[5]).toEqual(variableStateNotStarted(toVariablePayload(constant))); + expect(dispatchedActions[6]).toEqual(variableStateCompleted(toVariablePayload(constant))); + + expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid })); + return dispatchedActions.length === 8; + }); + }); + + describe('and there are variables that have data source that need to be migrated', () => { + it('then correct actions are dispatched', async () => { + const legacyDs = ('${ds}' as unknown) as DataSourceRef; + const ds = datasourceBuilder().withId('ds').withName('ds').withQuery('prom').build(); + const query = queryBuilder().withId('query').withName('query').withDatasource(legacyDs).build(); + const adhoc = adHocBuilder().withId('adhoc').withName('adhoc').withDatasource(legacyDs).build(); + const { uid, dashboard } = getTestContext([ds, query, adhoc]); + const tester = await reduxTester() + .givenRootReducer(getRootReducer()) + .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + + tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { + expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); + expect(dispatchedActions[1].type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.id).toEqual('__org'); + expect(dispatchedActions[3].type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.id).toEqual('__user'); + expect(dispatchedActions[4]).toEqual( + addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds })) + ); + expect(dispatchedActions[5]).toEqual( + addVariable(toVariablePayload(query, { global: false, index: 1, model: query })) + ); + expect(dispatchedActions[6]).toEqual( + addVariable(toVariablePayload(adhoc, { global: false, index: 2, model: adhoc })) + ); + expect(dispatchedActions[7]).toEqual(variableStateNotStarted(toVariablePayload(ds))); + expect(dispatchedActions[8]).toEqual(variableStateNotStarted(toVariablePayload(query))); + expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(adhoc))); + expect(dispatchedActions[10]).toEqual( + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ); + expect(dispatchedActions[11]).toEqual( + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ); + expect(dispatchedActions[12]).toEqual(variableStateFetching(toVariablePayload(ds))); + expect(dispatchedActions[13]).toEqual(variableStateCompleted(toVariablePayload(adhoc))); + expect(dispatchedActions[14]).toEqual( + createDataSourceOptions(toVariablePayload(ds, { sources: [], regex: undefined })) + ); + expect(dispatchedActions[15]).toEqual( + setCurrentVariableValue( + toVariablePayload(ds, { option: { selected: false, text: 'No data sources found', value: '' } }) + ) + ); + expect(dispatchedActions[16]).toEqual(variableStateCompleted(toVariablePayload(ds))); + expect(dispatchedActions[17]).toEqual(variableStateFetching(toVariablePayload(query))); + expect(dispatchedActions[18]).toEqual(variableStateCompleted(toVariablePayload(query))); + expect(dispatchedActions[19]).toEqual(variablesCompleteTransaction({ uid })); + + return dispatchedActions.length === 20; + }); + }); + }); + }); + + describe('when called and the previous dashboard is still processing variables', () => { + it('then correct actions are dispatched', async () => { + const { constant, uid, dashboard } = getTestContext(); + const transactionState = { uid: 'previous-uid', status: TransactionStatus.Fetching }; + + const tester = await reduxTester({ + preloadedState: ({ + templating: { + transaction: transactionState, + variables: {}, + optionsPicker: { ...initialState }, + editor: { ...initialVariableEditorState }, + }, + } as unknown) as RootReducerType, + }) + .givenRootReducer(getRootReducer()) + .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + + tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { + expect(dispatchedActions[0]).toEqual(cleanVariables()); + expect(dispatchedActions[1]).toEqual(cleanEditorState()); + expect(dispatchedActions[2]).toEqual(cleanPickerState()); + expect(dispatchedActions[3]).toEqual(variablesClearTransaction()); + expect(dispatchedActions[4]).toEqual(variablesInitTransaction({ uid })); + expect(dispatchedActions[5].type).toEqual(addVariable.type); + expect(dispatchedActions[5].payload.id).toEqual('__dashboard'); + expect(dispatchedActions[6].type).toEqual(addVariable.type); + expect(dispatchedActions[6].payload.id).toEqual('__org'); + expect(dispatchedActions[7].type).toEqual(addVariable.type); + expect(dispatchedActions[7].payload.id).toEqual('__user'); + expect(dispatchedActions[8]).toEqual( + addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + ); + expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(constant))); + expect(dispatchedActions[10]).toEqual(variableStateCompleted(toVariablePayload(constant))); + expect(dispatchedActions[11]).toEqual(variablesCompleteTransaction({ uid })); + return dispatchedActions.length === 12; + }); + }); + }); +}); diff --git a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts new file mode 100644 index 0000000000000..18a5cce11197f --- /dev/null +++ b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts @@ -0,0 +1,78 @@ +import { migrateVariablesDatasourceNameToRef } from './actions'; +import { adHocBuilder, queryBuilder } from '../shared/testing/builders'; +import { DataSourceRef } from '@grafana/data/src'; +import { changeVariableProp } from './sharedReducer'; +import { toVariablePayload } from './types'; + +function getTestContext(ds: DataSourceRef, dsInstance?: { uid: string; type: string }) { + jest.clearAllMocks(); + const query = queryBuilder().withId('query').withName('query').withDatasource(ds).build(); + const adhoc = adHocBuilder().withId('adhoc').withName('adhoc').withDatasource(ds).build(); + const state = { templating: { variables: [query, adhoc] } }; + const dispatch = jest.fn(); + const getState = jest.fn().mockReturnValue(state); + const getInstanceSettingsMock = jest.fn().mockReturnValue(dsInstance); + const getDatasourceSrvFunc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({}), + getList: jest.fn().mockReturnValue([]), + getInstanceSettings: getInstanceSettingsMock, + }); + + return { query, adhoc, dispatch, getState, getDatasourceSrvFunc }; +} + +describe('migrateVariablesDatasourceNameToRef', () => { + describe('when called and variables have legacy data source props', () => { + describe('and data source exists', () => { + it('then correct actions are dispatched', async () => { + const legacyDs = ('${ds}' as unknown) as DataSourceRef; + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, { + uid: 'a random uid', + type: 'prometheus', + }); + + migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls[0][0]).toEqual( + changeVariableProp( + toVariablePayload(query, { propName: 'datasource', propValue: { uid: 'a random uid', type: 'prometheus' } }) + ) + ); + expect(dispatch.mock.calls[1][0]).toEqual( + changeVariableProp( + toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: 'a random uid', type: 'prometheus' } }) + ) + ); + }); + }); + + describe('and data source does not exist', () => { + it('then correct actions are dispatched', async () => { + const legacyDs = ('${ds}' as unknown) as DataSourceRef; + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, undefined); + + migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls[0][0]).toEqual( + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ); + expect(dispatch.mock.calls[1][0]).toEqual( + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ); + }); + }); + }); + + describe('when called and variables have dataSourceRef', () => { + it('then no actions are dispatched', async () => { + const legacyDs = { uid: '${ds}', type: 'prometheus' }; + const { dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, undefined); + + migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + + expect(dispatch).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/public/app/features/variables/state/onTimeRangeUpdated.test.ts b/public/app/features/variables/state/onTimeRangeUpdated.test.ts index 6ca50e46b99c2..f7093bf18269d 100644 --- a/public/app/features/variables/state/onTimeRangeUpdated.test.ts +++ b/public/app/features/variables/state/onTimeRangeUpdated.test.ts @@ -24,12 +24,11 @@ import { notifyApp } from '../../../core/reducers/appNotification'; import { expect } from '../../../../test/lib/common'; import { TemplatingState } from './reducers'; import { appEvents } from '../../../core/core'; +import { variablesInitTransaction } from './transactionReducer'; variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]); -const dashboard = new DashboardModel({}); - -const getTestContext = () => { +const getTestContext = (dashboard: DashboardModel) => { jest.clearAllMocks(); const interval = intervalBuilder() @@ -60,12 +59,10 @@ const getTestContext = () => { const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock, events: appEvents }; const templateVariableValueUpdatedMock = jest.fn(); const startRefreshMock = jest.fn(); + dashboard.templateVariableValueUpdated = templateVariableValueUpdatedMock; + dashboard.startRefresh = startRefreshMock; const dashboardState = ({ - getModel: () => { - dashboard.templateVariableValueUpdated = templateVariableValueUpdatedMock; - dashboard.startRefresh = startRefreshMock; - return dashboard; - }, + getModel: () => dashboard, } as unknown) as DashboardState; const adapter = variableAdapters.get('interval'); const preloadedState = ({ @@ -100,13 +97,15 @@ describe('when onTimeRangeUpdated is dispatched', () => { updateTimeRangeMock, templateVariableValueUpdatedMock, startRefreshMock, - } = getTestContext(); + } = getTestContext(getDashboardModel()); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies)); tester.thenDispatchedActionsShouldEqual( + variablesInitTransaction({ uid: 'a uid' }), variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), setCurrentVariableValue( @@ -135,10 +134,11 @@ describe('when onTimeRangeUpdated is dispatched', () => { updateTimeRangeMock, templateVariableValueUpdatedMock, startRefreshMock, - } = getTestContext(); + } = getTestContext(getDashboardModel()); const base = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(setOptionAsCurrent(toVariableIdentifier(interval), interval.options[0], false)); const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); @@ -173,12 +173,13 @@ describe('when onTimeRangeUpdated is dispatched', () => { updateTimeRangeMock, templateVariableValueUpdatedMock, startRefreshMock, - } = getTestContext(); + } = getTestContext(getDashboardModel()); adapter.updateOptions = jest.fn().mockRejectedValue(new Error('Something broke')); const tester = await reduxTester({ preloadedState, debug: true }) .givenRootReducer(getRootReducer()) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { @@ -204,3 +205,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { }); }); }); + +function getDashboardModel(): DashboardModel { + return new DashboardModel({ schemaVersion: 9999 }); // ignore any schema migrations +} diff --git a/public/app/features/variables/state/upgradeLegacyQueries.test.ts b/public/app/features/variables/state/upgradeLegacyQueries.test.ts index ade2df94a1f3e..9430aef676296 100644 --- a/public/app/features/variables/state/upgradeLegacyQueries.test.ts +++ b/public/app/features/variables/state/upgradeLegacyQueries.test.ts @@ -5,13 +5,20 @@ import { upgradeLegacyQueries } from './actions'; import { changeVariableProp } from './sharedReducer'; import { thunkTester } from '../../../../test/core/thunk/thunkTester'; import { VariableModel } from '../types'; +import { TransactionStatus } from './transactionReducer'; interface Args { query?: any; variable?: VariableModel; datasource?: any; + transactionStatus?: TransactionStatus; } -function getTestContext({ query = '', variable, datasource }: Args = {}) { +function getTestContext({ + query = '', + variable, + datasource, + transactionStatus = TransactionStatus.Fetching, +}: Args = {}) { variable = variable ?? queryBuilder() @@ -22,6 +29,7 @@ function getTestContext({ query = '', variable, datasource }: Args = {}) { .build(); const state = { templating: { + transaction: { status: transactionStatus }, variables: { [variable.id]: variable, }, @@ -64,6 +72,22 @@ describe('upgradeLegacyQueries', () => { expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledWith({ uid: 'test-data', type: 'test-data' }); }); + + describe('but there is no ongoing transaction', () => { + it('then it should not dispatch changeVariableProp', async () => { + const { state, identifier, get, getDatasourceSrv } = getTestContext({ + query: '*', + transactionStatus: TransactionStatus.NotStarted, + }); + + const dispatchedActions = await thunkTester(state) + .givenThunk(upgradeLegacyQueries) + .whenThunkIsDispatched(identifier, getDatasourceSrv); + + expect(dispatchedActions).toEqual([]); + expect(get).toHaveBeenCalledTimes(0); + }); + }); }); describe('when called with a query variable for a standard variable supported data source that has been upgraded', () => { diff --git a/public/app/features/variables/types.ts b/public/app/features/variables/types.ts index eed6e0efd5a5c..ef9be5c0644e2 100644 --- a/public/app/features/variables/types.ts +++ b/public/app/features/variables/types.ts @@ -154,15 +154,20 @@ export type VariableQueryEditorType< > = ComponentType | ComponentType> | null; export interface VariablesChangedEvent { - panelIds?: number[]; + refreshAll: boolean; + panelIds: number[]; } export class VariablesChanged extends BusEventWithPayload { static type = 'variables-changed'; } -export class VariablesFinishedProcessingTimeRangeChange extends BusEventWithPayload { - static type = 'variables-finished-processing-time-range-change'; +export interface VariablesTimeRangeProcessDoneEvent { + variableIds: string[]; +} + +export class VariablesTimeRangeProcessDone extends BusEventWithPayload { + static type = 'variables-time-range-process-done'; } export class VariablesChangedInUrl extends BusEventWithPayload { diff --git a/public/app/features/variables/utils.ts b/public/app/features/variables/utils.ts index f0f0882c90ad0..180898c623a06 100644 --- a/public/app/features/variables/utils.ts +++ b/public/app/features/variables/utils.ts @@ -7,6 +7,9 @@ import { QueryVariableModel, VariableModel, VariableRefresh } from './types'; import { getTimeSrv } from '../dashboard/services/TimeSrv'; import { variableAdapters } from './adapters'; import { safeStringifyValue } from 'app/core/utils/explore'; +import { StoreState } from '../../types'; +import { getState } from '../../store/store'; +import { TransactionStatus } from './state/transactionReducer'; /* * This regex matches 3 types of variable reference with an optional format specifier @@ -253,3 +256,7 @@ export function ensureStringValues(value: any | any[]): string | string[] { return ''; } + +export function hasOngoingTransaction(state: StoreState = getState()): boolean { + return state.templating.transaction.status !== TransactionStatus.NotStarted; +} diff --git a/public/app/percona/add-instance/components/AddInstance/AddInstance.constants.ts b/public/app/percona/add-instance/components/AddInstance/AddInstance.constants.ts new file mode 100644 index 0000000000000..26760dafd78dd --- /dev/null +++ b/public/app/percona/add-instance/components/AddInstance/AddInstance.constants.ts @@ -0,0 +1,13 @@ +import { Messages } from './AddInstance.messages'; +import { InstanceTypesExtra } from '../../panel.types'; +import { Databases } from 'app/percona/shared/core'; + +export const instanceList = [ + { type: InstanceTypesExtra.rds, title: Messages.titles.rds }, + { type: Databases.postgresql, title: Messages.titles.postgresql }, + { type: Databases.mysql, title: Messages.titles.mysql }, + { type: Databases.mongodb, title: Messages.titles.mongodb }, + { type: Databases.proxysql, title: Messages.titles.proxysql }, + { type: InstanceTypesExtra.external, title: Messages.titles.external }, + { type: Databases.haproxy, title: Messages.titles.haproxy }, +]; diff --git a/public/app/percona/add-instance/components/AddInstance/AddInstance.messages.ts b/public/app/percona/add-instance/components/AddInstance/AddInstance.messages.ts new file mode 100644 index 0000000000000..3f8b44e707235 --- /dev/null +++ b/public/app/percona/add-instance/components/AddInstance/AddInstance.messages.ts @@ -0,0 +1,13 @@ +export const Messages = { + titles: { + rds: 'Amazon RDS', + azure: 'Microsoft Azure MySQL or PostgreSQL', + postgresql: 'PostgreSQL', + mysql: 'MySQL', + mongodb: 'MongoDB', + proxysql: 'ProxySQL', + haproxy: 'HAProxy', + external: 'External Service', + addInstance: 'Add a remote instance', + }, +}; diff --git a/public/app/percona/add-instance/components/AddInstance/AddInstance.styles.ts b/public/app/percona/add-instance/components/AddInstance/AddInstance.styles.ts new file mode 100644 index 0000000000000..e722402463531 --- /dev/null +++ b/public/app/percona/add-instance/components/AddInstance/AddInstance.styles.ts @@ -0,0 +1,51 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ border, colors, spacing, typography }: GrafanaTheme) => ({ + navigationButton: css` + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + padding-bottom: 1.2em; + margin: ${spacing.sm}; + border-radius: ${border.radius.md}; + width: 230px; + height: 160px; + text-align: center; + background-color: transparent; + border: ${border.width.sm} dashed ${colors.border2}; + + :hover { + cursor: pointer; + background-color: ${colors.dropdownOptionHoverBg}; + border: ${border.width.sm} solid ${colors.border2}; + } + `, + navigationPanel: css` + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + max-width: 800px; + width: 100%; + overflow: hidden; + `, + content: css` + display: flex; + flex-direction: column; + align-items: center; + margin-top: 2em; + `, + addInstance: css` + margin-top: ${spacing.sm}; + font-size: ${typography.size.sm}; + `, + addInstanceTitle: css` + margin-top: ${spacing.sm}; + overflow: hidden; + line-height: ${typography.lineHeight.md}; + width: 65%; + height: 3em; + `, +}); diff --git a/public/app/percona/add-instance/components/AddInstance/AddInstance.test.tsx b/public/app/percona/add-instance/components/AddInstance/AddInstance.test.tsx new file mode 100644 index 0000000000000..3ac3e05cd4bc9 --- /dev/null +++ b/public/app/percona/add-instance/components/AddInstance/AddInstance.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { AddInstance } from './AddInstance'; +import { instanceList } from './AddInstance.constants'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; + +jest.mock('app/percona/settings/Settings.service'); + +describe('AddInstance page::', () => { + it('should render a given number of links', async () => { + await waitFor(() => render( {}} />)); + + expect(screen.getAllByRole('button')).toHaveLength(instanceList.length); + instanceList.forEach((item) => { + expect(screen.getByTestId(`${item.type}-instance`)).toBeInTheDocument(); + }); + }); + + it('should render azure option', async () => { + await waitFor(() => render( {}} />)); + + expect(screen.getAllByRole('button')).toHaveLength(instanceList.length + 1); + instanceList.forEach((item) => { + expect(screen.getByTestId(`${item.type}-instance`)).toBeInTheDocument(); + }); + expect(screen.getByTestId('azure-instance')).toBeInTheDocument(); + }); + + it('should invoke a callback with a proper instance type', async () => { + const onSelectInstanceType = jest.fn(); + + render(); + + expect(onSelectInstanceType).toBeCalledTimes(0); + + const button = await screen.findByTestId('rds-instance'); + fireEvent.click(button); + + expect(onSelectInstanceType).toBeCalledTimes(1); + expect(onSelectInstanceType.mock.calls[0][0]).toStrictEqual({ type: 'rds' }); + }); +}); diff --git a/public/app/percona/add-instance/components/AddInstance/AddInstance.tsx b/public/app/percona/add-instance/components/AddInstance/AddInstance.tsx new file mode 100644 index 0000000000000..bada084a58383 --- /dev/null +++ b/public/app/percona/add-instance/components/AddInstance/AddInstance.tsx @@ -0,0 +1,61 @@ +import React, { FC, useMemo } from 'react'; +import { useStyles } from '@grafana/ui'; +import { Database } from 'app/percona/shared/components/Elements/Icons/Database'; +import { Databases } from 'app/percona/shared/core'; +import { getStyles } from './AddInstance.styles'; +import { Messages } from './AddInstance.messages'; +import { AddInstanceProps, SelectInstanceProps } from './AddInstance.types'; +import { InstanceTypesExtra, InstanceAvailableType } from '../../panel.types'; + +export const SelectInstance: FC = ({ type, selectInstanceType, title }) => { + const styles = useStyles(getStyles); + + return ( + + ); +}; + +export const AddInstance: FC = ({ onSelectInstanceType, showAzure }) => { + const styles = useStyles(getStyles); + const instanceList = useMemo( + () => [ + { type: InstanceTypesExtra.rds, title: Messages.titles.rds }, + { type: InstanceTypesExtra.azure, title: Messages.titles.azure, isHidden: !showAzure }, + { type: Databases.postgresql, title: Messages.titles.postgresql }, + { type: Databases.mysql, title: Messages.titles.mysql }, + { type: Databases.mongodb, title: Messages.titles.mongodb }, + { type: Databases.proxysql, title: Messages.titles.proxysql }, + { type: InstanceTypesExtra.external, title: Messages.titles.external }, + { type: Databases.haproxy, title: Messages.titles.haproxy }, + ], + [showAzure] + ); + + const selectInstanceType = (type: string) => () => onSelectInstanceType({ type: type as InstanceAvailableType }); + + return ( +
    + +
    + ); +}; diff --git a/public/app/percona/add-instance/components/AddInstance/AddInstance.types.ts b/public/app/percona/add-instance/components/AddInstance/AddInstance.types.ts new file mode 100644 index 0000000000000..01305b9320052 --- /dev/null +++ b/public/app/percona/add-instance/components/AddInstance/AddInstance.types.ts @@ -0,0 +1,12 @@ +import { InstanceAvailable } from '../../panel.types'; + +export interface SelectInstanceProps { + type: string; + title: string; + selectInstanceType: (type: string) => () => void; +} + +export interface AddInstanceProps { + onSelectInstanceType: (arg: InstanceAvailable) => void; + showAzure: boolean; +} diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.constants.ts b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.constants.ts new file mode 100644 index 0000000000000..e179907248307 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.constants.ts @@ -0,0 +1,12 @@ +import { Databases } from 'app/percona/shared/core'; + +export const DEFAULT_PORTS = { + [Databases.mysql]: '3306', + [Databases.mongodb]: '27017', + [Databases.postgresql]: '5432', + [Databases.proxysql]: '6032', + [Databases.haproxy]: '8404', +}; + +export const ADD_RDS_CANCEL_TOKEN = 'addRds'; +export const ADD_AZURE_CANCEL_TOKEN = 'addAzure'; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.messages.ts b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.messages.ts new file mode 100644 index 0000000000000..2f5b4055c7f5f --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.messages.ts @@ -0,0 +1,25 @@ +export const Messages = { + form: { + trackingOptions: { + none: "Don't track", + pgStatements: 'PG Stat Statements', + pgMonitor: 'PG Stat Monitor', + }, + schemaOptions: { + http: 'HTTP', + https: 'HTTPS', + }, + metricsParametersOptions: { + manually: 'Set manually', + parsed: 'Parse from URL string', + }, + buttons: { + addService: 'Add service', + toMenu: 'Return to menu', + }, + titles: { + addExternalService: 'Add external service', + addRemoteInstance: 'Add remote instance', + }, + }, +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.test.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.test.tsx new file mode 100644 index 0000000000000..4c1b08e7e71db --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.test.tsx @@ -0,0 +1,99 @@ +import { toExternalServicePayload, toPayload } from './AddRemoteInstance.service'; + +describe('AddRemoteInstanceService:: ', () => { + it('should properly convert remote external service form to a payload', () => { + const data = { + tracking: 'qan_postgresql_pgstatements_agent', + group: 'test', + url: 'http://admin:admin@localhost/graph', + schema: 'http', + address: 'localhost', + port: '80', + metrics_path: '/graph', + username: 'admin', + password: 'admin', + environment: 'test', + region: 'test', + az: 'test', + replication_set: 'test', + cluster: 'test', + custom_labels: 'test:test', + }; + + const payload = { + group: 'test', + service_name: 'localhost', + url: 'http://admin:admin@localhost/graph', + schema: 'http', + address: 'localhost', + metrics_path: '/graph', + username: 'admin', + password: 'admin', + environment: 'test', + region: 'test', + az: 'test', + replication_set: 'test', + cluster: 'test', + custom_labels: { + test: 'test', + }, + add_node: { + node_name: 'localhost', + node_type: 'REMOTE_NODE', + }, + listen_port: '80', + metrics_mode: 1, + }; + + expect(toExternalServicePayload(data)).toStrictEqual(payload); + }); + + it('should properly convert remote instance form to a payload', () => { + const data = { + tracking: 'qan_postgresql_pgstatements_agent', + group: 'test', + url: 'http://admin:admin@localhost/graph', + schema: 'http', + address: 'localhost', + port: '80', + metrics_path: '/graph', + username: 'admin', + password: 'admin', + environment: 'test', + region: 'test', + az: 'test', + replication_set: 'test', + cluster: 'test', + custom_labels: 'test:test', + }; + + const payload = { + group: 'test', + service_name: 'localhost', + url: 'http://admin:admin@localhost/graph', + schema: 'http', + address: 'localhost', + metrics_path: '/graph', + username: 'admin', + password: 'admin', + environment: 'test', + region: 'test', + az: 'test', + replication_set: 'test', + cluster: 'test', + custom_labels: { + test: 'test', + }, + add_node: { + node_name: 'localhost', + node_type: 'REMOTE_NODE', + }, + pmm_agent_id: 'pmm-server', + port: '80', + qan_postgresql_pgstatements_agent: true, + metrics_mode: 1, + }; + + expect(toPayload(data)).toStrictEqual(payload); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.tsx new file mode 100644 index 0000000000000..7620fafde0d70 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.service.tsx @@ -0,0 +1,224 @@ +import { CancelToken } from 'axios'; +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { + ExternalPayload, + RemoteInstancePayload, + TrackingOptions, + ProxySQLInstanceResponse, + PostgreSQLInstanceResponse, + MySQLInstanceResponse, + AddHaProxyResponse, + AddMongoDbResponse, + AddRDSResponse, + AddExternalResponse, + ErrorResponse, + RDSPayload, + MSAzurePayload, + MySQLPayload, + PostgreSQLPayload, + HaProxyPayload, + ProxySQLPayload, + MongoDBPayload, +} from './AddRemoteInstance.types'; +import { InstanceTypesExtra, InstanceAvailableType } from '../../panel.types'; +import { Databases } from 'app/percona/shared/core'; + +class AddRemoteInstanceService { + static async addMysql(body: MySQLPayload, token?: CancelToken) { + return apiManagement.post( + '/MySQL/Add', + body, + false, + token + ); + } + + static async addPostgresql(body: PostgreSQLPayload, token?: CancelToken) { + return apiManagement.post( + '/PostgreSQL/Add', + body, + false, + token + ); + } + + static async addProxysql(body: ProxySQLPayload, token?: CancelToken) { + return apiManagement.post( + '/ProxySQL/Add', + body, + false, + token + ); + } + + static async addHaproxy(body: HaProxyPayload, token?: CancelToken) { + return apiManagement.post( + '/HAProxy/Add', + body, + false, + token + ); + } + + static async addMongodb(body: MongoDBPayload, token?: CancelToken) { + return apiManagement.post( + '/MongoDB/Add', + body, + false, + token + ); + } + + static async addRDS(body: RDSPayload, token?: CancelToken) { + return apiManagement.post('/RDS/Add', body, false, token); + } + + static async addAzure(body: MSAzurePayload, token?: CancelToken) { + return apiManagement.post<{} | ErrorResponse, RemoteInstancePayload>( + '/azure/AzureDatabase/Add', + body, + false, + token + ); + } + + static async addExternal(body: ExternalPayload, token?: CancelToken) { + return apiManagement.post('/External/Add', body, false, token); + } + + static addRemote(type: InstanceAvailableType, data: RemoteInstancePayload, token?: CancelToken) { + switch (type) { + case Databases.mongodb: + return AddRemoteInstanceService.addMongodb(toPayload(data, '', type) as MongoDBPayload, token); + case Databases.mysql: + return AddRemoteInstanceService.addMysql(toPayload(data, '', type) as MySQLPayload, token); + case Databases.postgresql: + return AddRemoteInstanceService.addPostgresql(toPayload(data, '', type) as PostgreSQLPayload, token); + case Databases.proxysql: + return AddRemoteInstanceService.addProxysql(toPayload(data, '', type) as ProxySQLPayload, token); + case Databases.haproxy: + return AddRemoteInstanceService.addHaproxy(toExternalServicePayload(data), token); + case InstanceTypesExtra.external: + return AddRemoteInstanceService.addExternal(toExternalServicePayload(data), token); + default: + throw new Error('Unknown instance type'); + } + } +} + +export default AddRemoteInstanceService; + +export const toPayload = (values: any, discoverName?: string, type?: InstanceAvailableType): RemoteInstancePayload => { + const data = { ...values }; + + if (values.custom_labels) { + data.custom_labels = data.custom_labels + .split(/[\n\s]/) + .filter(Boolean) + .reduce((acc: any, val: string) => { + const [key, value] = val.split(':'); + + acc[key] = value; + + return acc; + }, {}); + } + + if (!values.isAzure) { + if (data.isRDS && data.tracking === TrackingOptions.pgStatements) { + data.qan_postgresql_pgstatements = true; + } else if (!data.isRDS && data.tracking === TrackingOptions.pgStatements) { + data.qan_postgresql_pgstatements_agent = true; + } else if (!data.isRDS && data.tracking === TrackingOptions.pgMonitor) { + data.qan_postgresql_pgstatmonitor_agent = true; + } + } + + data.service_name = data.serviceName; + delete data.serviceName; + + if (!data.service_name) { + data.service_name = data.address; + } + + if (!values.isAzure && data.add_node === undefined) { + data.add_node = { + node_name: data.service_name, + node_type: 'REMOTE_NODE', + }; + } + + if (values.isRDS && discoverName) { + data.engine = discoverName; + } + + if (values.isAzure && discoverName) { + data.type = discoverName; + } + + if (!data.pmm_agent_id) { + // set default value for pmm agent id + data.pmm_agent_id = 'pmm-server'; + } + + if (values.isRDS) { + data.rds_exporter = true; + } + + if (values.isAzure) { + data.node_name = data.service_name; + if (data.tracking === TrackingOptions.pgStatements || data.qan_mysql_perfschema) { + data.qan = true; + } + } + + if (type === Databases.mongodb) { + if (values.tls) { + data.authentication_mechanism = 'MONGODB-X509'; + } + } + + data.metrics_mode = 1; + delete data.tracking; + + return data; +}; + +export const toExternalServicePayload = (values: any): ExternalPayload => { + const data = { ...values }; + + if (values.custom_labels) { + data.custom_labels = data.custom_labels + .split(/[\n\s]/) + .filter(Boolean) + .reduce((acc: any, val: string) => { + const [key, value] = val.split(':'); + + acc[key] = value; + + return acc; + }, {}); + } + + delete data.tracking; + data.service_name = data.serviceName; + delete data.serviceName; + + if (!data.service_name) { + data.service_name = data.address; + } + + if (data.add_node === undefined) { + data.add_node = { + node_name: data.service_name, + node_type: 'REMOTE_NODE', + }; + } + + data.listen_port = data.port; + delete data.port; + + data.metrics_mode = 1; + + return data; +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.styles.ts b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.styles.ts new file mode 100644 index 0000000000000..0a78f852714e4 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.styles.ts @@ -0,0 +1,42 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + formWrapper: css` + background-color: transparent !important; + width: 100%; + `, + navigationPanel: css` + display: flex; + flex-direction: row; + justify-content: start; + flex-wrap: wrap; + max-width: 800px; + width: 100%; + overflow: hidden; + `, + content: css` + display: flex; + flex-direction: column; + align-items: center; + `, + addInstance: css` + margin-top: ${spacing.sm}; + `, + addInstanceTitle: css` + margin-top: ${spacing.sm}; + width: 65%; + height: 1.5em; + white-space: nowrap; + `, + addRemoteInstanceTitle: css` + text-align: center; + `, + addRemoteInstanceButtons: css` + margin-top: ${spacing.md}; + margin-bottom: ${spacing.md}; + `, + returnButton: css` + margin-left: ${spacing.md}; + `, +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.test.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.test.tsx new file mode 100644 index 0000000000000..d9eb830fdefc9 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AddRemoteInstance from './AddRemoteInstance'; +import { Databases } from 'app/percona/shared/core'; +import { InstanceTypesExtra } from '../../panel.types'; + +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); + +describe('Add remote instance:: ', () => { + it('should render correct for mysql and postgres and highlight empty mandatory fields on submit', async () => { + const type = Databases.mysql; + render(); + + expect(screen.getByTestId('address-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('username-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('password-password-input').classList.contains('invalid')).toBe(false); + + fireEvent.submit(screen.getByTestId('add-remote-instance-form'), {}); + + expect(screen.getByTestId('address-text-input').classList.contains('invalid')).toBe(true); + expect(screen.getByTestId('username-text-input').classList.contains('invalid')).toBe(true); + expect(screen.getByTestId('password-password-input').classList.contains('invalid')).toBe(true); + }); + + it('should render for external service and highlight empty mandatory fields on submit', async () => { + const type = InstanceTypesExtra.external; + render(); + + expect(screen.getByTestId('address-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('metrics_path-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('port-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('username-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('password-password-input').classList.contains('invalid')).toBe(false); + + fireEvent.submit(screen.getByTestId('add-remote-instance-form'), {}); + + expect(screen.getByTestId('address-text-input').classList.contains('invalid')).toBe(true); + expect(screen.getByTestId('metrics_path-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('port-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('username-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('password-password-input').classList.contains('invalid')).toBe(false); + }); + + it('should render correct for HAProxy and highlight empty mandatory fields on submit', async () => { + const type = Databases.haproxy; + + render(); + + expect(screen.getByTestId('address-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('username-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('password-password-input').classList.contains('invalid')).toBe(false); + + fireEvent.submit(screen.getByTestId('add-remote-instance-form'), {}); + + expect(screen.getByTestId('address-text-input').classList.contains('invalid')).toBe(true); + expect(screen.getByTestId('username-text-input').classList.contains('invalid')).toBe(false); + expect(screen.getByTestId('password-password-input').classList.contains('invalid')).toBe(false); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tools.test.ts b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tools.test.ts new file mode 100644 index 0000000000000..6a7aeb4288fd9 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tools.test.ts @@ -0,0 +1,128 @@ +import { getInstanceData } from './AddRemoteInstance.tools'; +import { Databases } from 'app/percona/shared/core'; + +describe('Get instance data:: ', () => { + it('should return correct one when isRDS is false', () => { + const instanceType = Databases.postgresql; + const credentials = { + isRDS: false, + address: 'test address', + instance_id: 'test instance id', + port: '5432', + region: 'us-west1', + aws_access_key: 'aws-secret-key-example', + aws_secret_key: 'aws-secret-key-example', + az: 'test az', + }; + const testInstance = { + instanceType: 'PostgreSQL', + remoteInstanceCredentials: { + port: '5432', + metricsParameters: 'manually', + schema: 'https', + }, + }; + + expect(getInstanceData(instanceType, credentials)).toEqual(testInstance); + }); + + it('get instance data should return correct one when isRDS is true', () => { + const instanceType = Databases.postgresql; + const credentials = { + isRDS: true, + address: 'test address', + instance_id: 'test instance id', + port: '5432', + region: 'us-west1', + aws_access_key: 'aws-secret-key-example', + aws_secret_key: 'aws-secret-key-example', + az: 'test az', + }; + const testInstance = { + instanceType: 'PostgreSQL', + discoverName: 'DISCOVER_RDS_POSTGRESQL', + remoteInstanceCredentials: { + isRDS: true, + address: 'test address', + instance_id: 'test instance id', + serviceName: 'test instance id', + port: '5432', + region: 'us-west1', + aws_access_key: 'aws-secret-key-example', + aws_secret_key: 'aws-secret-key-example', + az: 'test az', + }, + }; + + expect(getInstanceData(instanceType, credentials)).toEqual(testInstance); + }); + + it('get instance data should return correct data for MongoDB', () => { + const instanceType = Databases.mongodb; + const credentials = { + isRDS: false, + address: 'test address', + instance_id: 'test instance id', + region: 'us-west1', + aws_access_key: 'aws-secret-key-example', + aws_secret_key: 'aws-secret-key-example', + az: 'test az', + }; + const testInstance = { + instanceType: 'MongoDB', + remoteInstanceCredentials: { + port: '27017', + metricsParameters: 'manually', + schema: 'https', + }, + }; + + expect(getInstanceData(instanceType, credentials)).toEqual(testInstance); + }); + + it('get instance data should return correct data for MySQL', () => { + const instanceType = Databases.mysql; + const credentials = { + isRDS: false, + address: 'test address', + instance_id: 'test instance id', + region: 'us-west1', + aws_access_key: 'aws-secret-key-example', + aws_secret_key: 'aws-secret-key-example', + az: 'test az', + }; + const testInstance = { + instanceType: 'MySQL', + remoteInstanceCredentials: { + port: '3306', + metricsParameters: 'manually', + schema: 'https', + }, + }; + + expect(getInstanceData(instanceType, credentials)).toEqual(testInstance); + }); + + it('get instance data should return correct data for ProxySQL', () => { + const instanceType = Databases.proxysql; + const credentials = { + isRDS: false, + address: 'test address', + instance_id: 'test instance id', + region: 'us-west1', + aws_access_key: 'aws-secret-key-example', + aws_secret_key: 'aws-secret-key-example', + az: 'test az', + }; + const testInstance = { + instanceType: 'ProxySQL', + remoteInstanceCredentials: { + port: '6032', + metricsParameters: 'manually', + schema: 'https', + }, + }; + + expect(getInstanceData(instanceType, credentials)).toEqual(testInstance); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tools.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tools.tsx new file mode 100644 index 0000000000000..6e0082b025e47 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tools.tsx @@ -0,0 +1,149 @@ +import { InstanceData } from './AddRemoteInstance.types'; +import { INSTANCE_TYPES_LABELS, InstanceAvailableType } from '../../panel.types'; +import { DEFAULT_PORTS } from './AddRemoteInstance.constants'; +import { logger } from '@percona/platform-core'; +import { MetricsParameters, Schema } from './FormParts/FormParts.types'; +import { Databases } from 'app/percona/shared/core'; + +const getAzureCredentials = (credentials: any, instanceType: string) => { + const instance: InstanceData = { + remoteInstanceCredentials: { + serviceName: credentials.address, + port: credentials.port, + username: credentials.username, + address: credentials.address, + isAzure: true, + region: credentials.region, + azure_client_id: credentials.azure_client_id, + azure_client_secret: credentials.azure_client_secret, + azure_tenant_id: credentials.azure_tenant_id, + azure_subscription_id: credentials.azure_subscription_id, + azure_resource_group: credentials.azure_resource_group, + instance_id: credentials.instance_id, + az: credentials.az, + azure_database_exporter: true, + }, + }; + + switch (instanceType) { + case Databases.postgresql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.postgresql]; + instance.discoverName = 'DISCOVER_AZURE_DATABASE_TYPE_POSTGRESQL'; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.postgresql]; + break; + case Databases.mysql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.mysql]; + instance.discoverName = 'DISCOVER_AZURE_DATABASE_TYPE_MYSQL'; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.mysql]; + break; + case Databases.mariadb: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.mariadb]; + instance.discoverName = 'DISCOVER_AZURE_DATABASE_TYPE_MARIADB'; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.mysql]; + break; + default: + logger.error('Not implemented'); + } + + return instance; +}; + +const getRDSCredentials = (credentials: any, instanceType: InstanceAvailableType): InstanceData => { + const instance: InstanceData = { + remoteInstanceCredentials: { + serviceName: !credentials.isRDS ? credentials.address : credentials.instance_id, + port: credentials.port, + address: credentials.address, + isRDS: true, + region: credentials.region, + aws_access_key: credentials.aws_access_key, + aws_secret_key: credentials.aws_secret_key, + instance_id: credentials.instance_id, + az: credentials.az, + }, + }; + + switch (instanceType) { + case Databases.postgresql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.postgresql]; + instance.discoverName = 'DISCOVER_RDS_POSTGRESQL'; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.postgresql]; + break; + case Databases.mysql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.mysql]; + instance.discoverName = 'DISCOVER_RDS_MYSQL'; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.mysql]; + break; + case Databases.mongodb: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.mongodb]; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.mongodb]; + break; + case Databases.proxysql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.proxysql]; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.proxysql]; + break; + case Databases.haproxy: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.haproxy]; + instance.remoteInstanceCredentials.port = + instance.remoteInstanceCredentials.port || DEFAULT_PORTS[Databases.haproxy]; + break; + default: + logger.error('Not implemented'); + } + + return instance; +}; + +export const getInstanceData = (instanceType: InstanceAvailableType, credentials: any): InstanceData => { + const extractCredentials = (credentials?: any): InstanceData => { + if (credentials?.isRDS) { + return getRDSCredentials(credentials, instanceType); + } else if (credentials?.isAzure) { + return getAzureCredentials(credentials, instanceType); + } + + const instance: any = { + remoteInstanceCredentials: { + metricsParameters: MetricsParameters.manually, + schema: Schema.HTTPS, + }, + }; + + switch (instanceType) { + case Databases.postgresql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.postgresql]; + instance.remoteInstanceCredentials.port = DEFAULT_PORTS[Databases.postgresql]; + break; + case Databases.mysql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.mysql]; + instance.remoteInstanceCredentials.port = DEFAULT_PORTS[Databases.mysql]; + break; + case Databases.mongodb: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.mongodb]; + instance.remoteInstanceCredentials.port = DEFAULT_PORTS[Databases.mongodb]; + break; + case Databases.proxysql: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.proxysql]; + instance.remoteInstanceCredentials.port = DEFAULT_PORTS[Databases.proxysql]; + break; + case Databases.haproxy: + instance.instanceType = INSTANCE_TYPES_LABELS[Databases.haproxy]; + instance.remoteInstanceCredentials.port = DEFAULT_PORTS[Databases.haproxy]; + break; + default: + logger.error('Not implemented'); + } + return instance; + }; + + return extractCredentials(credentials); +}; + +export const remoteToken = (type: string) => `${type}Token`; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tsx new file mode 100644 index 0000000000000..ba785eaf8150b --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.tsx @@ -0,0 +1,153 @@ +/* eslint-disable react/display-name */ +import React, { FC, useCallback, useMemo, useState } from 'react'; +import { Form as FormFinal } from 'react-final-form'; +import { Button, useStyles } from '@grafana/ui'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { Databases } from 'app/percona/shared/core'; +import AddRemoteInstanceService, { toPayload } from './AddRemoteInstance.service'; +import { getInstanceData, remoteToken } from './AddRemoteInstance.tools'; +import { getStyles } from './AddRemoteInstance.styles'; +import { + AddRemoteInstanceProps, + FormValues, + RDSPayload, + MSAzurePayload, + TrackingOptions, +} from './AddRemoteInstance.types'; +import { AdditionalOptions, Labels, MainDetails, PostgreSQLConnectionDetails } from './FormParts'; +import { Messages } from './AddRemoteInstance.messages'; +import { ExternalServiceConnectionDetails } from './FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails'; +import { InstanceTypesExtra, InstanceTypes, INSTANCE_TYPES_LABELS, InstanceAvailableType } from '../../panel.types'; +import { HAProxyConnectionDetails } from './FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails'; +import { FormApi } from 'final-form'; +import { logger } from '@percona/platform-core'; +import { ADD_AZURE_CANCEL_TOKEN, ADD_RDS_CANCEL_TOKEN } from './AddRemoteInstance.constants'; + +const AddRemoteInstance: FC = ({ instance: { type, credentials }, selectInstance }) => { + const styles = useStyles(getStyles); + + const { remoteInstanceCredentials, discoverName } = getInstanceData(type, credentials); + const [loading, setLoading] = useState(false); + const [generateToken] = useCancelToken(); + const initialValues: FormValues = { ...remoteInstanceCredentials }; + + if (type === Databases.mysql) { + initialValues.qan_mysql_perfschema = true; + } + + if (type === Databases.postgresql) { + initialValues.tracking = TrackingOptions.pgStatements; + } + + const onSubmit = useCallback( + async (values: FormValues) => { + try { + setLoading(true); + if (values.isRDS) { + await AddRemoteInstanceService.addRDS( + toPayload(values, discoverName) as RDSPayload, + generateToken(ADD_RDS_CANCEL_TOKEN) + ); + } else if (values.isAzure) { + await AddRemoteInstanceService.addAzure( + toPayload(values, discoverName) as MSAzurePayload, + generateToken(ADD_AZURE_CANCEL_TOKEN) + ); + } else { + await AddRemoteInstanceService.addRemote(type, values, generateToken(remoteToken(type))); + } + + window.location.href = '/graph/inventory/'; + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setLoading(false); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [type, discoverName] + ); + + const ConnectionDetails = useCallback( + ({ form, type }: { form: FormApi; type: InstanceTypes | '' }) => { + switch (type) { + case InstanceTypesExtra.external: + return ; + case Databases.haproxy: + return ; + case Databases.postgresql: + return ; + default: + return ; + } + }, + [remoteInstanceCredentials] + ); + + const formParts = useMemo( + () => (form: FormApi) => ( + <> + + + {type !== InstanceTypesExtra.external && ( + + )} + + ), + [ConnectionDetails, loading, remoteInstanceCredentials, type] + ); + + const getHeader = (databaseType: InstanceAvailableType) => { + if (databaseType === InstanceTypesExtra.external) { + return Messages.form.titles.addExternalService; + } + if (databaseType === '') { + return Messages.form.titles.addRemoteInstance; + } + return `Add remote ${INSTANCE_TYPES_LABELS[databaseType]} Instance`; + }; + + return ( +
    + { + changeValue(state, field, () => value); + }, + }} + render={({ form, handleSubmit }) => ( + +

    {getHeader(type)}

    + {formParts(form)} +
    + + +
    + + )} + /> +
    + ); +}; + +export default AddRemoteInstance; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.types.ts b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.types.ts new file mode 100644 index 0000000000000..a1eba8de2688f --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/AddRemoteInstance.types.ts @@ -0,0 +1,417 @@ +import { RemoteInstanceCredentials, InstanceAvailable, SelectInstance } from 'app/percona/add-instance/panel.types'; + +export interface FormValues extends RemoteInstanceCredentials { + qan_mysql_perfschema?: boolean; + tracking?: 'qan_postgresql_pgstatements_agent'; +} + +export enum TrackingOptions { + none = 'none', + pgStatements = 'qan_postgresql_pgstatements_agent', + pgMonitor = 'qan_postgresql_pgstatmonitor_agent', +} + +export interface InstanceData { + instanceType?: string; + defaultPort?: number; + remoteInstanceCredentials: RemoteInstanceCredentials; + discoverName?: string; +} + +export interface AddRemoteInstanceProps { + instance: InstanceAvailable; + selectInstance: SelectInstance; +} + +export enum DefaultPorts { + small = 'small', + medium = 'medium', + large = 'large', + custom = 'custom', +} + +// export interface RemoteInstancePayload { +// custom_labels: {}; +// service_name: string; +// address?: string; +// listen_port: string; +// metrics_mode: number; +// node_name?: string; +// qan?: string; +// } + +interface AddNode { + node_name: string; + node_type: string; + machine_id: string; + distro: string; + container_id: string; + container_name: string; + node_model: string; + region: string; + az: string; + custom_labels: {}; +} + +interface RemoteCommonPayload { + node_name?: string; + address?: string; + service_name?: string; + username?: string; + password?: string; + environment?: string; + custom_labels?: any; + skip_connection_check?: boolean; +} +interface TLSCommon { + tls?: boolean; + tls_skip_verify?: boolean; +} + +export interface ProxySQLPayload extends RemoteCommonPayload, TLSCommon { + node_id: string; + add_node: AddNode; + port: number; + socket: string; + pmm_agent_id: string; + cluster: string; + replication_set: string; + metrics_mode: string; + disable_collectors: string[]; + agent_password: string; +} + +export interface PostgreSQLPayload extends RemoteCommonPayload, TLSCommon { + node_id: string; + add_node: AddNode; + port: number; + socket: string; + pmm_agent_id: string; + cluster: string; + replication_set: string; + qan_postgresql_pgstatements_agent: boolean; + qan_postgresql_pgstatmonitor_agent: boolean; + disable_query_examples: boolean; + metrics_mode: string; + disable_collectors: string[]; + tls_ca: string; + tls_cert: string; + tls_key: string; + agent_password: string; +} + +export interface MySQLPayload extends RemoteCommonPayload, TLSCommon { + node_id: string; + add_node: AddNode; + port: number; + socket: string; + pmm_agent_id: string; + cluster: string; + replication_set: string; + qan_mysql_perfschema: boolean; + qan_mysql_slowlog: boolean; + disable_query_examples: boolean; + max_slowlog_file_size: string; + tablestats_group_table_limit: number; + metrics_mode: string; + disable_collectors: string[]; + agent_password: string; + tls_cert: string; + tls_key: string; + tls_ca: string; +} + +export interface MongoDBPayload extends RemoteCommonPayload, TLSCommon { + node_id: string; + add_node: AddNode; + port: number; + socket: string; + pmm_agent_id: string; + cluster: string; + replication_set: string; + qan_mongodb_profiler: boolean; + tls_certificate_key: string; + tls_certificate_key_file_password: string; + tls_ca: string; + metrics_mode: string; + disable_collectors: string[]; + authentication_mechanism: string; + authentication_database: string; + agent_password: string; +} + +export interface HaProxyPayload extends RemoteCommonPayload { + node_id: string; + add_node: AddNode; + scheme: string; + metrics_path: string; + listen_port: number; + cluster: string; + replication_set: string; + metrics_mode: string; +} + +export interface ExternalPayload extends RemoteCommonPayload { + runs_on_node_id: string; + add_node: AddNode; + scheme: string; + metrics_path: string; + listen_port: number; + node_id: string; + cluster: string; + replication_set: string; + group: string; + metrics_mode: string; +} + +export interface CommonRDSAzurePayload extends RemoteCommonPayload, TLSCommon { + region?: string; + az?: string; + instance_id?: string; + node_model?: string; + port?: string; + tablestats_group_table_limit?: number; + disable_query_examples?: boolean; +} + +export interface RDSPayload extends CommonRDSAzurePayload { + engine: string; + cluster: string; + replication_set: string; + aws_access_key: string; + aws_secret_key: string; + rds_exporter: boolean; + qan_mysql_perfschema: boolean; + disable_basic_metrics: boolean; + disable_enhanced_metrics: boolean; + metrics_mode: string; + qan_postgresql_pgstatements: boolean; + agent_password: string; +} + +export interface MSAzurePayload extends CommonRDSAzurePayload { + azure_client_id?: string; + azure_client_secret?: string; + azure_tenant_id?: string; + azure_subscription_id?: string; + azure_resource_group?: string; + azure_database_exporter?: boolean; + qan?: boolean; + type?: string; +} + +export type RemoteInstancePayload = + | MSAzurePayload + | RDSPayload + | MySQLPayload + | HaProxyPayload + | ProxySQLPayload + | PostgreSQLPayload + | MongoDBPayload + | ExternalPayload; + +export interface ErrorResponse { + error: string; + code: number; + message: string; + details: [ + { + type_url: string; + value: string; + } + ]; +} + +interface Service { + service_id: string; + service_name: string; + node_id: string; + socket: string; + environment: string; + cluster: string; + replication_set: string; + custom_labels: {}; +} + +interface ExtendedService extends Service { + address: string; + port: number; +} +interface BaseExporter { + agent_id: string; + pmm_agent_id: string; + disabled: boolean; + service_id: string; + username: string; + tls: boolean; + tls_skip_verify: boolean; + custom_labels: {}; + status: string; +} + +interface Node { + node_id: string; + node_name: string; + address: string; + node_model: string; + region: string; + az: string; + custom_labels: {}; +} + +interface MySQLExporter extends BaseExporter { + tls_ca: string; + tls_cert: string; + tls_key: string; + tablestats_group_table_limit: number; + push_metrics_enabled: boolean; + tablestats_group_disabled: boolean; + disabled_collectors: string[]; + listen_port: number; +} + +interface MySQLPerfschema extends BaseExporter { + tls_ca: string; + tls_cert: string; + tls_key: string; + query_examples_disabled: boolean; +} + +interface MySQLShowLog extends MySQLPerfschema { + max_slowlog_file_size: string; +} + +interface PostgreSQLExporter extends BaseExporter { + push_metrics_enabled: boolean; + disabled_collectors: string[]; + listen_port: number; +} + +interface PgStatMonitorAgent extends BaseExporter { + query_examples_disabled: boolean; +} + +interface ProxySQLExporter extends BaseExporter { + push_metrics_enabled: boolean; + disabled_collectors: string[]; + listen_port: number; +} + +interface MongoDbExporter extends BaseExporter { + push_metrics_enabled: boolean; + disabled_collectors: string[]; + listen_port: number; +} + +export interface MySQLInstanceResponse { + service: ExtendedService; + mysqld_exporter: MySQLExporter; + qan_mysql_perfschema: MySQLPerfschema; + qan_mysql_slowlog: MySQLShowLog; + table_count: number; +} + +export interface PostgreSQLInstanceResponse { + service: ExtendedService; + postgres_exporter: PostgreSQLExporter; + qan_postgresql_pgstatements_agent: BaseExporter; + qan_postgresql_pgstatmonitor_agent: PgStatMonitorAgent; +} + +export interface ProxySQLInstanceResponse { + service: ExtendedService; + proxysql_exxporter: ProxySQLExporter; +} + +export interface AddHaProxyResponse { + service: Service; + external_exporter: { + agent_id: string; + runs_on_node_id: string; + disabled: true; + service_id: string; + username: string; + scheme: string; + metrics_path: string; + custom_labels: {}; + listen_port: number; + push_metrics_enabled: true; + }; +} + +export interface AddMongoDbResponse { + service: ExtendedService; + mongodb_exporter: MongoDbExporter; + qan_mongodb_profiler: BaseExporter; +} + +export interface AddRDSResponse { + node: Node; + rds_exporter: { + agent_id: string; + pmm_agent_id: string; + disabled: boolean; + node_id: string; + aws_access_key: string; + custom_labels: {}; + status: string; + listen_port: number; + basic_metrics_disabled: boolean; + enhanced_metrics_disabled: boolean; + push_metrics_enabled: boolean; + }; + mysql: { + service_id: string; + service_name: string; + node_id: string; + address: string; + port: number; + socket: string; + environment: string; + cluster: string; + replication_set: string; + custom_labels: {}; + }; + mysqld_exporter: MySQLExporter; + qan_mysql_perfschema: MySQLPerfschema; + table_count: number; + postgresql: { + service_id: string; + service_name: string; + node_id: string; + address: string; + port: number; + socket: string; + environment: string; + cluster: string; + replication_set: string; + custom_labels: {}; + }; + postgresql_exporter: PostgreSQLExporter; + qan_postgresql_pgstatements: BaseExporter; +} + +export interface AddExternalResponse { + service: { + service_id: string; + service_name: string; + node_id: string; + environment: string; + cluster: string; + replication_set: string; + custom_labels: {}; + group: string; + }; + external_exporter: { + agent_id: string; + runs_on_node_id: string; + disabled: boolean; + service_id: string; + username: string; + scheme: string; + metrics_path: string; + custom_labels: {}; + listen_port: number; + push_metrics_enabled: boolean; + }; +} diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.constants.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.constants.ts new file mode 100644 index 0000000000000..535ff0d3fd8ef --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.constants.ts @@ -0,0 +1,17 @@ +import { Messages } from './AdditionalOptions.messages'; +import { TablestatOptionsInterface } from './AdditionalOptions.types'; + +export const tablestatOptions = [ + { + value: TablestatOptionsInterface.disabled, + label: Messages.form.tablestatOptions.disabled, + }, + { + value: TablestatOptionsInterface.default, + label: Messages.form.tablestatOptions.default, + }, + { + value: TablestatOptionsInterface.custom, + label: Messages.form.tablestatOptions.custom, + }, +]; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.messages.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.messages.ts new file mode 100644 index 0000000000000..4e89563764044 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.messages.ts @@ -0,0 +1,9 @@ +export const Messages = { + form: { + tablestatOptions: { + disabled: 'Disabled', + default: 'Default', + custom: 'Custom', + }, + }, +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx new file mode 100644 index 0000000000000..9fc967adf4f47 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.tsx @@ -0,0 +1,181 @@ +import React, { FC, useEffect, useState } from 'react'; +import { + CheckboxField, + NumberInputField, + RadioButtonGroupField, + validators as platformCoreValidators, +} from '@percona/platform-core'; +import { useStyles } from '@grafana/ui'; +import { AdditionalOptionsFormPartProps, PostgreSQLAdditionalOptionsProps } from '../FormParts.types'; +import { getStyles } from '../FormParts.styles'; +import { Messages } from '../FormParts.messages'; +import { rdsTrackingOptions, trackingOptions } from '../FormParts.constants'; +import { tablestatOptions } from './AdditionalOptions.constants'; +import { TablestatOptionsInterface } from './AdditionalOptions.types'; +import { FormApi } from 'final-form'; +import { InstanceAvailableType, RemoteInstanceCredentials } from 'app/percona/add-instance/panel.types'; +import { MysqlTLSCertificate } from './MysqlTLSCertificate'; +import { MongodbTLSCertificate } from './MongodbTLSCertificate'; +import { PostgreTLSCertificate } from './PostgreTLSCertificate'; +import { Databases } from 'app/percona/shared/core'; + +export const AdditionalOptionsFormPart: FC = ({ + instanceType, + remoteInstanceCredentials, + form, +}) => { + const styles = useStyles(getStyles); + + return ( +
    +

    {Messages.form.titles.additionalOptions}

    +
    + + {getAdditionalOptions(instanceType, remoteInstanceCredentials, form)} +
    +
    + ); +}; + +export const PostgreSQLAdditionalOptions: FC = ({ isRDS, isAzure }) => ( + <> + + +); + +const getTablestatValues = (type: TablestatOptionsInterface) => { + switch (type) { + case TablestatOptionsInterface.disabled: + return -1; + default: + return 1000; + } +}; + +const MySQLOptions = ({ form }: { form: FormApi }) => { + const selectedOption = form.getState().values && form.getState().values['tablestat-options']; + const [selectedValue, setSelectedValue] = useState(selectedOption || TablestatOptionsInterface.disabled); + + useEffect(() => { + setSelectedValue(selectedOption); + form.change('tablestats_group_table_limit', getTablestatValues(selectedOption)); + }, [selectedOption, form]); + + return ( + <> + + + + ); +}; + +export const getAdditionalOptions = ( + type: InstanceAvailableType, + remoteInstanceCredentials: RemoteInstanceCredentials, + form: FormApi +) => { + switch (type) { + case Databases.postgresql: + return ( + <> + + + + + {remoteInstanceCredentials.isRDS ? ( + <> + + + + ) : null} + {remoteInstanceCredentials.isAzure ? ( + + ) : null} + + ); + case Databases.mysql: + return ( + <> + + + + + + {remoteInstanceCredentials.isRDS ? ( + <> + + + + ) : null} + {remoteInstanceCredentials.isAzure ? ( + + ) : null} + + ); + case Databases.mongodb: + return ( + <> + + + + + + ); + case Databases.haproxy: + return null; + default: + return ( + <> + + + + ); + } +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.types.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.types.ts new file mode 100644 index 0000000000000..f58a325d28110 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/AdditionalOptions.types.ts @@ -0,0 +1,5 @@ +export enum TablestatOptionsInterface { + disabled = 'disabled', + default = 'default', + custom = 'custom', +} diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/MongodbTLSCertificate.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/MongodbTLSCertificate.tsx new file mode 100644 index 0000000000000..744080728770c --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/MongodbTLSCertificate.tsx @@ -0,0 +1,33 @@ +import { PasswordInputField, TextareaInputField } from '@percona/platform-core'; +import { Messages } from '../FormParts.messages'; +import React, { FC } from 'react'; +import { FormPartProps } from '../FormParts.types'; + +export const MongodbTLSCertificate: FC = ({ form }) => { + const tlsFlag = form.getState().values && form.getState().values['tls']; + + return ( + <> + {tlsFlag ? ( + <> + + + + + ) : null} + + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/MysqlTLSCertificate.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/MysqlTLSCertificate.tsx new file mode 100644 index 0000000000000..785accd7d541c --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/MysqlTLSCertificate.tsx @@ -0,0 +1,35 @@ +import { TextareaInputField } from '@percona/platform-core'; +import { Messages } from '../FormParts.messages'; +import React, { FC } from 'react'; +import { FormPartProps } from '../FormParts.types'; + +export const MysqlTLSCertificate: FC = ({ form }) => { + const tlsFlag = form.getState().values && form.getState().values['tls']; + + return ( + <> + {tlsFlag ? ( + <> + + + + + ) : null} + + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/PostgreTLSCertificate.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/PostgreTLSCertificate.tsx new file mode 100644 index 0000000000000..02822c777501f --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/AdditionalOptions/PostgreTLSCertificate.tsx @@ -0,0 +1,36 @@ +import { TextareaInputField } from '@percona/platform-core'; +import { Messages } from '../FormParts.messages'; +import React, { FC } from 'react'; +import { FormPartProps } from '../FormParts.types'; + +export const PostgreTLSCertificate: FC = ({ form }) => { + const values = form.getState().values; + const tlsFlag = values && values['tls']; + + return ( + <> + {tlsFlag ? ( + <> + + + + + ) : null} + + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.test.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.test.tsx new file mode 100644 index 0000000000000..4a4e706e70129 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { Form } from 'react-final-form'; +import { ExternalServiceConnectionDetails } from './ExternalServiceConnectionDetails'; + +describe('Add remote instance:: ', () => { + it('should render correct for mysql and postgres and highlight empty mandatory fields on submit', async () => { + render( +
    { + changeValue(state, field, () => value); + }, + }} + render={({ form }) => } + /> + ); + + const metricsParametrsRadioState = screen.getByTestId('metricsParameters-radio-state'); + fireEvent.change(metricsParametrsRadioState, { target: { value: 'parsed' } }); + + const urlTextInput = screen.getByTestId('url-text-input'); + fireEvent.change(urlTextInput, { target: { value: 'https://admin:admin@localhost/metrics' } }); + + fireEvent.change(metricsParametrsRadioState, { target: { value: 'manually' } }); + + expect((screen.getByTestId('address-text-input') as HTMLInputElement).value).toEqual('localhost'); + expect((screen.getByTestId('metrics_path-text-input') as HTMLInputElement).value).toEqual('/metrics'); + expect((screen.getByTestId('port-text-input') as HTMLInputElement).value).toEqual('443'); + expect((screen.getByTestId('username-text-input') as HTMLInputElement).value).toEqual('admin'); + expect((screen.getByTestId('password-password-input') as HTMLInputElement).value).toEqual('admin'); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx new file mode 100644 index 0000000000000..3a54cfbc3ae89 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/ExternalServiceConnectionDetails/ExternalServiceConnectionDetails.tsx @@ -0,0 +1,128 @@ +import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import { PasswordInputField, RadioButtonGroupField, TextInputField, validators } from '@percona/platform-core'; +import Validators from 'app/percona/shared/helpers/validators'; +import { LinkTooltip } from 'app/percona/shared/components/Elements/LinkTooltip/LinkTooltip'; +import { useStyles } from '@grafana/ui'; +import { FormPartProps, MetricsParameters, Schema } from '../FormParts.types'; +import { getStyles } from '../FormParts.styles'; +import { Messages } from '../FormParts.messages'; +import { metricsParametersOptions, schemaOptions } from '../FormParts.constants'; + +export const ExternalServiceConnectionDetails: FC = ({ form }) => { + const styles = useStyles(getStyles); + const formValues = form.getState().values; + const selectedOption = formValues?.metricsParameters; + const urlValue = formValues?.url; + const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + + const trim = useCallback((value) => (value ? value.trim() : value), []); + const getUrlParts = () => { + try { + const url = new URL(form.getState().values.url); + const protocol = url.protocol.replace(':', ''); + + form.mutators?.setValue('schema', protocol); + form.mutators?.setValue('address', url.hostname); + form.mutators?.setValue('port', url.port || (protocol === 'https' ? '443' : '80')); + form.mutators?.setValue('metrics_path', url.pathname); + form.mutators?.setValue('username', url.username); + form.mutators?.setValue('password', url.password); + } catch (e) { + form.mutators?.setValue('schema', Schema.HTTPS); + form.mutators?.setValue('address', ''); + form.mutators?.setValue('port', '443'); + form.mutators?.setValue('metrics_path', ''); + form.mutators?.setValue('username', ''); + form.mutators?.setValue('password', ''); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(getUrlParts, [urlValue]); + + return ( +
    +

    {Messages.form.titles.connectionDetails}

    +
    + {Messages.form.labels.externalService.serviceName} + +
    + +
    + {Messages.form.labels.externalService.group} + +
    + +
    + {Messages.form.labels.externalService.connectionParameters} + +
    + + {selectedOption === MetricsParameters.parsed && ( + <> +
    + {Messages.form.labels.externalService.url} + +
    +
    + +
    + + )} + {selectedOption === MetricsParameters.manually && ( + <> +
    + {Messages.form.labels.externalService.schema} + +
    + +
    + {Messages.form.labels.externalService.address} + +
    + +
    + {Messages.form.labels.externalService.metricsPath} + +
    + +
    + {Messages.form.labels.externalService.port} + +
    + +
    + {Messages.form.labels.externalService.username} + +
    + +
    + {Messages.form.labels.externalService.password} + +
    + + + )} +
    + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.constants.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.constants.ts new file mode 100644 index 0000000000000..a97307f19fa32 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.constants.ts @@ -0,0 +1,25 @@ +import { SelectableValue } from '@grafana/data'; +import { Messages } from '../AddRemoteInstance.messages'; +import { TrackingOptions } from '../AddRemoteInstance.types'; +import { MetricsParameters, Schema } from './FormParts.types'; + +export const trackingOptions = [ + { value: TrackingOptions.none, label: Messages.form.trackingOptions.none }, + { value: TrackingOptions.pgStatements, label: Messages.form.trackingOptions.pgStatements }, + { value: TrackingOptions.pgMonitor, label: Messages.form.trackingOptions.pgMonitor }, +]; + +export const rdsTrackingOptions = [ + { value: TrackingOptions.none, label: Messages.form.trackingOptions.none }, + { value: TrackingOptions.pgStatements, label: Messages.form.trackingOptions.pgStatements }, +]; + +export const schemaOptions: SelectableValue[] = [ + { value: Schema.HTTPS, label: Messages.form.schemaOptions.https }, + { value: Schema.HTTP, label: Messages.form.schemaOptions.http }, +]; + +export const metricsParametersOptions: SelectableValue[] = [ + { value: MetricsParameters.manually, label: Messages.form.metricsParametersOptions.manually }, + { value: MetricsParameters.parsed, label: Messages.form.metricsParametersOptions.parsed }, +]; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts new file mode 100644 index 0000000000000..9cc61ed1382b4 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.messages.ts @@ -0,0 +1,130 @@ +export const Messages = { + form: { + trackingOptions: { + none: "Don't track", + pgStatements: 'PG Stat Statements', + pgMonitor: 'PG Stat Monitor', + }, + labels: { + trackingOptions: 'Stat tracking options', + externalService: { + url: 'External service endpoint', + connectionParameters: 'External service connection parameters', + schema: 'Schema', + metricsPath: 'Metrics path', + group: 'Group', + address: 'External service hostname', + serviceName: 'Service name', + port: 'External service port', + username: 'Username', + password: 'Password', + }, + mainDetails: { + address: 'Hostname', + serviceName: 'Service name', + port: 'Port', + username: 'Username', + password: 'Password', + }, + postgresqlDetails: { + database: 'Database', + }, + labels: { + environment: 'Environment', + region: 'Region', + az: 'Availability Zone', + replicationSet: 'Replication set', + cluster: 'Cluster', + customLabels: 'Custom labels', + }, + tooltips: { + tlsCA: 'Content of the trusted certificate authority (CA) file used to sign client certificates', + tlsCertificateKey: 'Content of the client private key file', + tlsCertificate: + 'Content of the client certificate file signed by one of the trusted certificate authorities (CAs)', + }, + additionalOptions: { + skipConnectionCheck: 'Skip connection check', + tls: 'Use TLS for database connections', + tlsCertificateFilePassword: 'TLS certificate password', + tlsCertificateKey: 'TLS certificate key', + tlsCertificate: 'TLS certificate', + tlsCA: 'TLS CA', + tlsSkipVerify: 'Skip TLS certificate and hostname validation', + qanMysqlPerfschema: 'Use performance schema', + qanMongodbProfiler: 'Use QAN MongoDB Profiler', + disableBasicMetrics: 'Disable Basic Metrics', + disableEnchancedMetrics: 'Disable Enhanced Metrics', + tablestatOptions: 'Table statistics limit', + azureDatabaseExporter: 'Enable Monitoring by Azure Metrics Exporter', + }, + }, + placeholders: { + externalService: { + url: 'http://example.com:3333/path/to/metrics', + metricsPath: '/path/to/metrics', + address: 'Hostname', + serviceName: 'Service name (default: Hostname)', + username: 'Username', + password: 'Password', + }, + mainDetails: { + address: 'Hostname', + serviceName: 'Service name (default: Hostname)', + username: 'Username', + password: 'Password', + }, + postgresqlDetails: { + database: 'Database (default: postgres)', + }, + labels: { + environment: 'Environment', + region: 'Region', + az: 'Availability Zone', + replicationSet: 'Replication set', + cluster: 'Cluster', + customLabels: '"Custom labels\n Format:\n key1:value1\n key2:value2"', + }, + additionalOptions: {}, + }, + tooltips: { + externalService: { + url: 'Your external service endpoint', + schema: 'Used version of protocol', + metricsPath: 'Path to your metrics', + group: 'Group to which your service belongs', + address: 'Public DNS hostname of your instance', + serviceName: 'Service name to use', + port: 'Port your external service is listening on', + username: 'Your external service username', + password: 'Your external service password', + }, + mainDetails: { + address: 'Public DNS hostname of your instance', + serviceName: 'Service name to use', + port: 'Port your service is listening on', + username: 'Your database user name', + password: 'Your database password', + }, + postgresqlDetails: { + database: 'Database name', + }, + haproxy: { + port: 'HAProxy prometheus exporter port', + username: 'username for HAProxy prometheus exporter', + password: 'password for HAProxy prometheus exporter', + }, + labels: { + region: 'Region', + az: 'Availability Zone', + }, + }, + titles: { + mainDetails: 'Main details', + labels: 'Labels', + additionalOptions: 'Additional options', + parseURL: 'Parse URL', + connectionDetails: 'External service connection details', + }, + }, +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.styles.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.styles.tsx new file mode 100644 index 0000000000000..364b063b2f446 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.styles.tsx @@ -0,0 +1,34 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + groupWrapper: css` + width: 50%; + `, + addServiceButton: css` + margin-top: 30px; + `, + sectionHeader: css` + margin-top: 15px; + margin-bottom: 15px; + `, + // Temporary solution, will be removed after tooltip labels will be added to platform inputs + labelWrapper: css` + display: flex; + font-weight: 500; + color: rgb(159, 167, 179); + svg { + margin-left: ${spacing.xs}; + } + margin-bottom: ${spacing.xs}; + `, + urlFieldGroupWrapper: css` + display: flex; + justify-content: space-between; + align-items: baseline; + `, + urlFieldWrapper: css` + width: 100%; + margin-right: 5px; + `, +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.test.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.test.tsx new file mode 100644 index 0000000000000..4274b7ea8cfce --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { Form } from 'react-final-form'; +import { render, screen } from '@testing-library/react'; +import { FormApi, FormState } from 'final-form'; +import { trackingOptions, rdsTrackingOptions } from './FormParts.constants'; +import { AdditionalOptionsFormPart, getAdditionalOptions } from './AdditionalOptions/AdditionalOptions'; +import { LabelsFormPart } from './Labels/Labels'; +import { MainDetailsFormPart } from './MainDetails/MainDetails'; +import { ExternalServiceConnectionDetails } from './ExternalServiceConnectionDetails/ExternalServiceConnectionDetails'; +import { Databases } from 'app/percona/shared/core'; + +const form: Partial = { + change: jest.fn(), + getState: () => ({} as FormState), +}; + +describe('MainDetailsFormPart ::', () => { + it('should disable fields with sat isRDS flag', async () => { + const { container } = render( + } + /> + ); + + const fields = container.querySelectorAll('input'); + expect(fields.length).toBe(5); + + expect(screen.getByTestId('address-text-input')).toBeDisabled(); + expect(screen.getByTestId('serviceName-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('port-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('username-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('password-password-input')).not.toBeDisabled(); + }); + + it('should disable fields with not sat isRDS flag', async () => { + const { container } = render( + } + /> + ); + + const fields = container.querySelectorAll('input'); + expect(fields.length).toBe(5); + + expect(screen.getByTestId('address-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('serviceName-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('port-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('username-text-input')).not.toBeDisabled(); + expect(screen.getByTestId('password-password-input')).not.toBeDisabled(); + }); +}); + +describe('ExternalServiceConnectionDetails ::', () => { + it('should render', async () => { + const { container } = render( + } + /> + ); + + const fields = container.querySelectorAll('input'); + expect(fields.length).toBe(5); + }); +}); + +describe('LabelsFormPart ::', () => { + it('should render correct fields with empty props', async () => { + const { container } = render( } />); + + const fields = container.querySelectorAll('input'); + const textArea = container.querySelectorAll('textarea'); + + expect(fields.length).toBe(5); + expect(textArea.length).toBe(1); + }); +}); + +describe('AdditionalOptionsFormPart ::', () => { + it('should render correct for PostgreSQL instance', async () => { + const type = Databases.postgresql; + const remoteInstanceCredentials = { + isRDS: false, + }; + + render( + ( + + )} + /> + ); + + expect(screen.getByTestId('skip_connection_check-checkbox-input')).toBeInTheDocument(); + expect(screen.getByTestId('tls-checkbox-input')).toBeInTheDocument(); + expect(screen.getByTestId('tls_skip_verify-checkbox-input')).toBeInTheDocument(); + }); +}); + +describe('getAdditionalOptions ::', () => { + it('should render correct for MongoDB', async () => { + const type = Databases.mongodb; + const remoteInstanceCredentials = { + isRDS: false, + }; + + const { container } = render( + getAdditionalOptions(type, remoteInstanceCredentials, form as FormApi)} + /> + ); + const fields = container.querySelectorAll('input'); + + expect(screen.getByTestId('tls-checkbox-input')).toBeInTheDocument(); + expect(screen.getByTestId('tls_skip_verify-checkbox-input')).toBeInTheDocument(); + expect(screen.getByTestId('qan_mongodb_profiler-checkbox-input')).toBeInTheDocument(); + + expect(fields.length).toBe(3); + }); + it('should render correct for MySQL', async () => { + const type = Databases.mysql; + const remoteInstanceCredentials = { + isRDS: false, + }; + + const { container } = render( + getAdditionalOptions(type, remoteInstanceCredentials, form as FormApi)} + /> + ); + const fields = container.querySelectorAll('input'); + + expect(screen.getByTestId('qan_mysql_perfschema-checkbox-input')).toBeInTheDocument(); + expect(fields.length).toBe(8); + }); + it('should render correct for RDS MySQL', async () => { + const type = Databases.mysql; + const remoteInstanceCredentials = { + isRDS: true, + }; + + const { container } = render( + getAdditionalOptions(type, remoteInstanceCredentials, form as FormApi)} + /> + ); + const fields = container.querySelectorAll('input'); + expect(screen.getByTestId('qan_mysql_perfschema-checkbox-input')).toBeInTheDocument(); + expect(screen.getByTestId('disable_basic_metrics-checkbox-input')).toBeInTheDocument(); + expect(screen.getByTestId('disable_enhanced_metrics-checkbox-input')).toBeInTheDocument(); + expect(fields.length).toBe(10); + }); + it('should render correct for PostgreSQL', async () => { + const type = Databases.postgresql; + const remoteInstanceCredentials = { + isRDS: false, + }; + + const { container } = render( + getAdditionalOptions(type, remoteInstanceCredentials, form as FormApi)} + /> + ); + const fields = container.querySelectorAll('input'); + const trakingFields = screen.getAllByTestId('tracking-radio-button'); + expect(trakingFields.length).toBe(trackingOptions.length); + expect(fields.length).toBe(6); + }); + it('should render correct for RDS PostgreSQL', async () => { + const type = Databases.postgresql; + const remoteInstanceCredentials = { + isRDS: true, + }; + + const { container } = render( + getAdditionalOptions(type, remoteInstanceCredentials, form as FormApi)} + /> + ); + const fields = container.querySelectorAll('input'); + const trakingFields = screen.getAllByTestId('tracking-radio-button'); + expect(trakingFields.length).toBe(rdsTrackingOptions.length); + expect(fields.length).toBe(7); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.types.ts b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.types.ts new file mode 100644 index 0000000000000..1751e19f15487 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/FormParts.types.ts @@ -0,0 +1,33 @@ +import { InstanceAvailableType, RemoteInstanceCredentials } from 'app/percona/add-instance/panel.types'; +import { FormApi } from 'final-form'; + +export interface MainDetailsFormPartProps { + remoteInstanceCredentials: RemoteInstanceCredentials; + form?: FormApi; +} + +export interface FormPartProps { + form: FormApi; +} + +export interface AdditionalOptionsFormPartProps { + instanceType: InstanceAvailableType; + loading: boolean; + remoteInstanceCredentials: RemoteInstanceCredentials; + form: FormApi; +} + +export interface PostgreSQLAdditionalOptionsProps { + isRDS?: boolean; + isAzure?: boolean; +} + +export enum Schema { + HTTP = 'http', + HTTPS = 'https', +} + +export enum MetricsParameters { + manually = 'manually', + parsed = 'parsed', +} diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails.test.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails.test.tsx new file mode 100644 index 0000000000000..50b8c45650068 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Form } from 'react-final-form'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { HAProxyConnectionDetails } from './HAProxyConnectionDetails'; + +describe('HAProxy connection details:: ', () => { + it('should trim username and password values right', () => { + render( } />); + + const userNameTextInput = screen.getByTestId('username-text-input'); + fireEvent.change(userNameTextInput, { target: { value: ' test ' } }); + const passwordInput = screen.getByTestId('password-password-input'); + fireEvent.change(passwordInput, { target: { value: ' test ' } }); + + expect(screen.getByTestId('username-text-input') as HTMLInputElement).toHaveValue('test'); + expect(screen.getByTestId('password-password-input') as HTMLInputElement).toHaveValue('test'); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails.tsx new file mode 100644 index 0000000000000..9f21f085b4e1f --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/HAProxyConnectionDetails/HAProxyConnectionDetails.tsx @@ -0,0 +1,54 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { PasswordInputField, TextInputField, validators } from '@percona/platform-core'; +import Validators from 'app/percona/shared/helpers/validators'; +import { LinkTooltip } from 'app/percona/shared/components/Elements/LinkTooltip/LinkTooltip'; +import { useStyles } from '@grafana/ui'; +import { MainDetailsFormPartProps } from '../FormParts.types'; +import { getStyles } from '../FormParts.styles'; +import { Messages } from '../FormParts.messages'; + +export const HAProxyConnectionDetails: FC = ({ remoteInstanceCredentials }) => { + const styles = useStyles(getStyles); + + const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + const trim = useCallback((value) => (value ? value.trim() : value), []); + + return ( +
    +

    {Messages.form.titles.mainDetails}

    +
    + {Messages.form.labels.mainDetails.address} + +
    + +
    + {Messages.form.labels.mainDetails.serviceName} + +
    + +
    + {Messages.form.labels.mainDetails.port} + +
    + +
    + {Messages.form.labels.mainDetails.username} + +
    + +
    + {Messages.form.labels.mainDetails.password} + +
    + +
    + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/Labels/Labels.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/Labels/Labels.tsx new file mode 100644 index 0000000000000..7c3c6304cadca --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/Labels/Labels.tsx @@ -0,0 +1,49 @@ +import React, { FC, useMemo } from 'react'; +import { TextareaInputField, TextInputField } from '@percona/platform-core'; +import Validators from 'app/percona/shared/helpers/validators'; +import { useStyles } from '@grafana/ui'; +import { LinkTooltip } from 'app/percona/shared/components/Elements/LinkTooltip/LinkTooltip'; +import { getStyles } from '../FormParts.styles'; +import { Messages } from '../FormParts.messages'; + +export const LabelsFormPart: FC = () => { + const styles = useStyles(getStyles); + const customLabelsValidators = useMemo(() => [Validators.validateKeyValue], []); + + return ( +
    +

    {Messages.form.titles.labels}

    + +
    + {Messages.form.labels.labels.region} + +
    + +
    + {Messages.form.labels.labels.az} + +
    + + + + +
    + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx new file mode 100644 index 0000000000000..7632e2b315bdf --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/MainDetails/MainDetails.tsx @@ -0,0 +1,67 @@ +import React, { FC, useMemo } from 'react'; +import { PasswordInputField, TextInputField, validators } from '@percona/platform-core'; +import Validators from 'app/percona/shared/helpers/validators'; +import { LinkTooltip } from 'app/percona/shared/components/Elements/LinkTooltip/LinkTooltip'; +import { useStyles } from '@grafana/ui'; +import { MainDetailsFormPartProps } from '../FormParts.types'; +import { getStyles } from '../FormParts.styles'; +import { Messages } from '../FormParts.messages'; + +export const MainDetailsFormPart: FC = ({ form, remoteInstanceCredentials }) => { + const styles = useStyles(getStyles); + const formValues = form && form.getState().values; + const tlsFlag = formValues && formValues['tls']; + + const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); + + return ( +
    +

    {Messages.form.titles.mainDetails}

    +
    + {Messages.form.labels.mainDetails.address} + +
    + +
    + {Messages.form.labels.mainDetails.serviceName} + +
    + +
    + {Messages.form.labels.mainDetails.port} + +
    + +
    + {Messages.form.labels.mainDetails.username} + +
    + +
    + {Messages.form.labels.mainDetails.password} + +
    + +
    + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.test.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.test.tsx new file mode 100644 index 0000000000000..2ea0637fbb78f --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Form } from 'react-final-form'; +import { PostgreSQLConnectionDetails } from './PostgreSQLConnectionDetails'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('PostgreSQL connection details:: ', () => { + it('should have database attribute', () => { + render( } />); + + const textInput = screen.getByTestId('database-text-input'); + fireEvent.change(textInput, { target: { value: 'db1' } }); + + expect(screen.getByTestId('database-text-input')).toHaveValue('db1'); + }); +}); diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx new file mode 100644 index 0000000000000..eb7ee161ffa65 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/PostgreSQLConnectionDetails/PostgreSQLConnectionDetails.tsx @@ -0,0 +1,77 @@ +import React, { FC, useMemo } from 'react'; +import { PasswordInputField, TextInputField, validators } from '@percona/platform-core'; +import Validators from 'app/percona/shared/helpers/validators'; +import { LinkTooltip } from 'app/percona/shared/components/Elements/LinkTooltip/LinkTooltip'; +import { useTheme } from '@grafana/ui'; +import { MainDetailsFormPartProps } from '../FormParts.types'; +import { getStyles } from '../FormParts.styles'; +import { Messages } from '../FormParts.messages'; + +export const PostgreSQLConnectionDetails: FC = ({ form, remoteInstanceCredentials }) => { + const theme = useTheme(); + const styles = getStyles(theme); + const formValues = form && form.getState().values; + const tlsFlag = formValues && formValues['tls']; + + const portValidators = useMemo(() => [validators.required, Validators.validatePort], []); + const userPassValidators = useMemo(() => (tlsFlag ? [] : [validators.required]), [tlsFlag]); + + return ( +
    +

    {Messages.form.titles.mainDetails}

    +
    + {Messages.form.labels.mainDetails.address} + +
    + +
    + {Messages.form.labels.mainDetails.serviceName} + +
    + +
    + {Messages.form.labels.mainDetails.port} + +
    + +
    + {Messages.form.labels.mainDetails.username} + +
    + +
    + {Messages.form.labels.mainDetails.password} + +
    + +
    + {Messages.form.labels.postgresqlDetails.database} + +
    + +
    + ); +}; diff --git a/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/index.tsx b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/index.tsx new file mode 100644 index 0000000000000..0d15707b2a875 --- /dev/null +++ b/public/app/percona/add-instance/components/AddRemoteInstance/FormParts/index.tsx @@ -0,0 +1,4 @@ +export { AdditionalOptionsFormPart as AdditionalOptions } from './AdditionalOptions/AdditionalOptions'; +export { LabelsFormPart as Labels } from './Labels/Labels'; +export { MainDetailsFormPart as MainDetails } from './MainDetails/MainDetails'; +export { PostgreSQLConnectionDetails } from './PostgreSQLConnectionDetails/PostgreSQLConnectionDetails'; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/Discovery.constants.ts b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.constants.ts new file mode 100644 index 0000000000000..2e2619d0143ea --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.constants.ts @@ -0,0 +1 @@ +export const DISCOVERY_AZURE_CANCEL_TOKEN = 'discoveryAzure'; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/Discovery.service.ts b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.service.ts new file mode 100644 index 0000000000000..7c851c5c76cb7 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.service.ts @@ -0,0 +1,25 @@ +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { AzureCredentialsForm } from './components/Credentials/Credentials.types'; +import { AzureDatabaseInstances } from './Discovery.types'; + +class DiscoveryService { + static async discoveryAzure( + { azure_client_id, azure_client_secret, azure_tenant_id, azure_subscription_id }: AzureCredentialsForm, + token?: CancelToken + ) { + return apiManagement.post( + '/azure/AzureDatabase/Discover', + { + azure_client_id, + azure_client_secret, + azure_tenant_id, + azure_subscription_id, + }, + false, + token + ); + } +} + +export default DiscoveryService; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/Discovery.styles.ts b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.styles.ts new file mode 100644 index 0000000000000..8dbbc0d675b73 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.styles.ts @@ -0,0 +1,10 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + content: css` + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + `, +}); diff --git a/public/app/percona/add-instance/components/AzureDiscovery/Discovery.test.tsx b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.test.tsx new file mode 100644 index 0000000000000..0cdbb609a729f --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import Discovery from './Discovery'; + +describe('Discovery instance:: ', () => { + it('Should render correct', () => { + const selectInstance = jest.fn(); + + render(); + expect(screen.getByTestId('azure_client_id-text-input')).toBeInTheDocument(); + expect(screen.getByTestId('azure_client_secret-password-input')).toBeInTheDocument(); + expect(screen.getByTestId('azure_tenant_id-text-input')).toBeInTheDocument(); + expect(screen.getByTestId('azure_subscription_id-text-input')).toBeInTheDocument(); + expect(screen.getByTestId('credentials-search-button')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/add-instance/components/AzureDiscovery/Discovery.tsx b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.tsx new file mode 100644 index 0000000000000..c70068092f592 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.tsx @@ -0,0 +1,60 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import DiscoveryService from './Discovery.service'; +import Credentials from './components/Credentials/Credentials'; +import Instances from './components/Instances/Instances'; +import { useStyles } from '@grafana/ui'; +import { getStyles } from './Discovery.styles'; +import { DiscoverySearchPanelProps, Instance } from './Discovery.types'; +import { AzureCredentialsForm } from './components/Credentials/Credentials.types'; +import { logger } from '@percona/platform-core'; +import { DISCOVERY_AZURE_CANCEL_TOKEN } from './Discovery.constants'; + +const Discovery: FC = ({ selectInstance }) => { + const styles = useStyles(getStyles); + + const [instances, setInstances] = useState([]); + const [credentials, setCredentials] = useState({}); + const [loading, startLoading] = useState(false); + const [generateToken] = useCancelToken(); + + useEffect(() => { + const updateInstances = async () => { + try { + const result = await DiscoveryService.discoveryAzure(credentials, generateToken(DISCOVERY_AZURE_CANCEL_TOKEN)); + if (result) { + setInstances(result.azure_database_instance); + } + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + startLoading(false); + }; + + if ( + credentials.azure_client_id && + credentials.azure_client_secret && + credentials.azure_tenant_id && + credentials.azure_subscription_id + ) { + startLoading(true); + updateInstances(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [credentials]); + + return ( + <> +
    + + +
    + + ); +}; + +export default Discovery; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/Discovery.types.ts b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.types.ts new file mode 100644 index 0000000000000..df068febc96e4 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/Discovery.types.ts @@ -0,0 +1,21 @@ +import { SelectInstance } from '../../panel.types'; + +export interface DiscoverySearchPanelProps { + selectInstance: SelectInstance; +} + +export interface Instance { + region: string; + az: string; + instance_id: string; + node_model: string; + address: string; + port: number; + type: string; + engine_version: string; + isAzure?: boolean; +} + +export interface AzureDatabaseInstances { + azure_database_instance: Instance[]; +} diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.constants.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.constants.ts new file mode 100644 index 0000000000000..82e9c17794658 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.constants.ts @@ -0,0 +1 @@ +export const SECURITY_CREDENTIALS_DOC_LINK = 'https://per.co.na/QcYQRx'; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.messages.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.messages.ts new file mode 100644 index 0000000000000..6c4e435e04767 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.messages.ts @@ -0,0 +1,29 @@ +export const Messages = { + form: { + fields: { + clientId: { + placeholder: 'Client ID', + name: 'azure_client_id', + label: 'Client ID', + }, + clientSecret: { + placeholder: 'Client Secret', + name: 'azure_client_secret', + label: 'Client Secret', + }, + tenantId: { + placeholder: 'Tenant ID', + name: 'azure_tenant_id', + label: 'Tenant ID', + }, + subscriptionId: { + placeholder: 'Subscription ID', + name: 'azure_subscription_id', + label: 'Subscription ID', + }, + }, + submitButton: 'Discover', + toMenuButton: 'Return to menu', + credentialsDocLink: 'Where do I get the security credentials for my Microsoft Azure DB instance', + }, +}; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.styles.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.styles.ts new file mode 100644 index 0000000000000..6873e34bf16e6 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.styles.ts @@ -0,0 +1,23 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + instanceForm: css` + padding: 0px; + margin-bottom: ${spacing.sm}; + `, + searchPanel: css` + display: flex; + justify-content: space-between; + width: 100%; + align-items: baseline; + `, + credentialsField: css` + width: 48%; + `, + credentialsSubmit: css` + margin-top: 2px; + margin-left: ${spacing.md}; + margin-right: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.tsx b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.tsx new file mode 100644 index 0000000000000..5703421303b6b --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.tsx @@ -0,0 +1,73 @@ +import { Form as FormFinal } from 'react-final-form'; +import React, { FC, useCallback } from 'react'; +import { TextInputField, validators, PasswordInputField } from '@percona/platform-core'; +import { Button, useStyles } from '@grafana/ui'; +import { getStyles } from './Credentials.styles'; +import { SECURITY_CREDENTIALS_DOC_LINK } from './Credentials.constants'; +import { Messages } from './Credentials.messages'; +import { AzureCredentialsForm, CredentialsProps } from './Credentials.types'; + +const Credentials: FC = ({ onSetCredentials, selectInstance }) => { + const styles = useStyles(getStyles); + + const onSubmit = useCallback((values: AzureCredentialsForm) => { + onSetCredentials({ ...values }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + ( + +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + )} + /> + ); +}; + +export default Credentials; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.types.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.types.ts new file mode 100644 index 0000000000000..09f4f2fa15c77 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Credentials/Credentials.types.ts @@ -0,0 +1,15 @@ +import { SelectInstance } from 'app/percona/add-instance/panel.types'; + +export interface AzureCredentialsForm { + azure_client_id?: string; + azure_client_secret?: string; + azure_tenant_id?: string; + azure_subscription_id?: string; + azure_resource_group?: string; + azure_database_exporter?: boolean; +} + +export interface CredentialsProps { + onSetCredentials: (credentials: AzureCredentialsForm) => void; + selectInstance: SelectInstance; +} diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.messages.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.messages.ts new file mode 100644 index 0000000000000..5c5e2789847e0 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + notAvailableType: 'nothing', +}; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.styles.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.styles.ts new file mode 100644 index 0000000000000..ce39dc90ab6c5 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.styles.ts @@ -0,0 +1,12 @@ +import { css } from '@emotion/css'; + +export const styles = { + tableWrapper: css` + margin-top: 10px; + width: 100%; + `, + actionButtonWrapper: css` + diplay: flex; + justify-content: center; + `, +}; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.tsx b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.tsx new file mode 100644 index 0000000000000..76110f6c70627 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { Table } from 'app/percona/shared/components/Elements/Table/Table'; +import { styles } from './Instances.styles'; +import { InstancesTableProps } from './Instances.types'; +import { getInstancesColumns } from './InstancesColumns'; + +const Instances: FC = (props) => { + const { instances, selectInstance, credentials, loading } = props; + + const columns = getInstancesColumns(credentials, selectInstance); + + return ( +
    + + + ); +}; + +export default Instances; diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.types.ts b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.types.ts new file mode 100644 index 0000000000000..869259bf28410 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/Instances.types.ts @@ -0,0 +1,14 @@ +import { Instance } from '../../Discovery.types'; +import { AzureCredentialsForm } from '../Credentials/Credentials.types'; +import { SelectInstance } from 'app/percona/add-instance/panel.types'; + +export type AzureCredentials = AzureCredentialsForm & { + isAzure?: boolean; +}; + +export interface InstancesTableProps { + instances: Instance[]; + selectInstance: SelectInstance; + loading: boolean; + credentials: AzureCredentialsForm; +} diff --git a/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/InstancesColumns.tsx b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/InstancesColumns.tsx new file mode 100644 index 0000000000000..ed4b162b43274 --- /dev/null +++ b/public/app/percona/add-instance/components/AzureDiscovery/components/Instances/InstancesColumns.tsx @@ -0,0 +1,78 @@ +/* eslint-disable react/display-name */ +import { Button } from '@grafana/ui'; +import React from 'react'; +import { DATABASE_LABELS, Databases } from 'app/percona/shared/core'; +import { styles } from './Instances.styles'; +import { Instance } from '../../Discovery.types'; +import { Messages } from './Instances.messages'; +import { InstanceAvailableType, SelectInstance } from 'app/percona/add-instance/panel.types'; +import { AzureCredentialsForm } from '../Credentials/Credentials.types'; + +const getEngineType = (type?: string) => { + switch (type) { + case 'DISCOVER_AZURE_DATABASE_TYPE_MYSQL': + return DATABASE_LABELS[Databases.mysql]; + case 'DISCOVER_AZURE_DATABASE_TYPE_MARIADB': + return DATABASE_LABELS[Databases.mariadb]; + case 'DISCOVER_AZURE_DATABASE_TYPE_POSTGRESQL': + return DATABASE_LABELS[Databases.postgresql]; + case 'DISCOVER_AZURE_DATABASE_INVALID': + return 'Unknown type'; + default: + return 'Unknown type'; + } +}; + +const getDatabaseType = (type?: string): InstanceAvailableType => { + switch (type) { + case 'DISCOVER_AZURE_DATABASE_TYPE_MYSQL': + case 'DISCOVER_AZURE_DATABASE_TYPE_MARIADB': + return Databases.mysql; + case 'DISCOVER_AZURE_DATABASE_TYPE_POSTGRESQL': + return Databases.postgresql; + default: + return ''; + } +}; + +export const getInstancesColumns = (credentials: AzureCredentialsForm, onSelectInstance: SelectInstance) => [ + { + Header: 'Region', + accessor: 'region', + }, + { + Header: 'Resource group', + accessor: 'azure_resource_group', + }, + { + Header: 'Name', + accessor: 'service_name', + }, + { + Header: 'Engine', + accessor: (element: Instance) => (element.type ? `${getEngineType(element.type)}` : Messages.notAvailableType), + }, + { + Header: 'Address', + accessor: 'address', + }, + { + Header: 'Action', + accessor: (element: Instance) => { + const selectionHandler = () => { + onSelectInstance({ + type: getDatabaseType(element.type), + credentials: { ...{ ...element, ...credentials }, isAzure: true }, + }); + }; + + return ( +
    + +
    + ); + }, + }, +]; diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.constants.ts b/public/app/percona/add-instance/components/Discovery/Discovery.constants.ts new file mode 100644 index 0000000000000..8839b67a4e2b9 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.constants.ts @@ -0,0 +1,2 @@ +export const DISCOVERY_RDS_CANCEL_TOKEN = 'discoveryRds'; +export const INITIAL_CREDENTIALS = { aws_secret_key: '', aws_access_key: '' }; diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.messages.ts b/public/app/percona/add-instance/components/Discovery/Discovery.messages.ts new file mode 100644 index 0000000000000..be622213699a2 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + awsNoCredentialsError: 'NoCredentialProviders', + noCredentialsError: 'No credentials provided and IAM role is not defined', +}; diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.service.ts b/public/app/percona/add-instance/components/Discovery/Discovery.service.ts new file mode 100644 index 0000000000000..6c01d0fb9f4b9 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.service.ts @@ -0,0 +1,38 @@ +import { AppEvents } from '@grafana/data'; +import { appEvents } from 'app/core/app_events'; +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { RDSCredentialsForm } from './components/Credentials/Credentials.types'; +import { RDSInstances } from './Discovery.types'; +import { Messages } from './Discovery.messages'; + +const { awsNoCredentialsError, noCredentialsError } = Messages; + +class DiscoveryService { + static async discoveryRDS( + { aws_access_key, aws_secret_key }: RDSCredentialsForm, + token?: CancelToken, + disableNotifications = false + ) { + return apiManagement + .post( + '/RDS/Discover', + { + aws_access_key, + aws_secret_key, + }, + true, + token + ) + .catch((e) => { + if (!disableNotifications) { + const originalMessage: string = e.response.data?.message ?? 'Unknown error'; + const message = originalMessage.includes(awsNoCredentialsError) ? noCredentialsError : originalMessage; + + appEvents.emit(AppEvents.alertError, [message]); + } + }); + } +} + +export default DiscoveryService; diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.styles.ts b/public/app/percona/add-instance/components/Discovery/Discovery.styles.ts new file mode 100644 index 0000000000000..d89af355ea1c2 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.styles.ts @@ -0,0 +1,23 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ colors, spacing }: GrafanaTheme) => ({ + content: css` + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + `, + infoWrapper: css` + display: flex; + flex-direction: column; + background-color: ${colors.bg2}; + padding: ${spacing.sm}; + margin-top: ${spacing.md}; + margin-bottom: ${spacing.md}; + `, + infoItems: css` + list-style-position: inside; + margin-left: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.test.tsx b/public/app/percona/add-instance/components/Discovery/Discovery.test.tsx new file mode 100644 index 0000000000000..47865983b14f7 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Discovery from './Discovery'; +import { render, screen, waitFor } from '@testing-library/react'; + +describe('Discovery:: ', () => { + it('should render credentials, instances and docs', async () => { + await waitFor(() => render()); + + expect(screen.getByTestId('credentials-form')).toBeInTheDocument(); + expect(screen.getByTestId('instances-table-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('discovery-docs')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.tsx b/public/app/percona/add-instance/components/Discovery/Discovery.tsx new file mode 100644 index 0000000000000..618b1b7eabdaf --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.tsx @@ -0,0 +1,67 @@ +import React, { FC, useEffect, useState, useCallback } from 'react'; +import { useStyles } from '@grafana/ui'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import DiscoveryService from './Discovery.service'; +import Credentials from './components/Credentials/Credentials'; +import Instances from './components/Instances/Instances'; +import { getStyles } from './Discovery.styles'; +import { DiscoverySearchPanelProps, Instance } from './Discovery.types'; +import { RDSCredentialsForm } from './components/Credentials/Credentials.types'; +import { logger } from '@percona/platform-core'; +import { DISCOVERY_RDS_CANCEL_TOKEN, INITIAL_CREDENTIALS } from './Discovery.constants'; +import { DiscoveryDocs } from './components/DiscoveryDocs/DiscoveryDocs'; + +const Discovery: FC = ({ selectInstance }) => { + const styles = useStyles(getStyles); + + const [instances, setInstances] = useState([]); + const [credentials, setCredentials] = useState(INITIAL_CREDENTIALS); + const [loading, startLoading] = useState(false); + const [generateToken] = useCancelToken(); + + const discover = useCallback( + async (credentials: RDSCredentialsForm, disableNotifications = false) => { + try { + setCredentials(credentials); + startLoading(true); + + const result = await DiscoveryService.discoveryRDS( + credentials, + generateToken(DISCOVERY_RDS_CANCEL_TOKEN), + disableNotifications + ); + + if (result) { + setInstances(result.rds_instances); + } + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } finally { + startLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setCredentials, setInstances] + ); + + useEffect(() => { + discover(INITIAL_CREDENTIALS, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> +
    + + + +
    + + ); +}; + +export default Discovery; diff --git a/public/app/percona/add-instance/components/Discovery/Discovery.types.ts b/public/app/percona/add-instance/components/Discovery/Discovery.types.ts new file mode 100644 index 0000000000000..2a8ca2cd5e237 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/Discovery.types.ts @@ -0,0 +1,18 @@ +import { SelectInstance } from '../../panel.types'; +export interface DiscoverySearchPanelProps { + selectInstance: SelectInstance; +} + +export interface Instance { + region: string; + az: string; + instance_id: string; + node_model: string; + address: string; + port: number; + engine: string; + engine_version: string; +} +export interface RDSInstances { + rds_instances: Instance[]; +} diff --git a/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.messages.ts b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.messages.ts new file mode 100644 index 0000000000000..ffc9f0ab02ede --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.messages.ts @@ -0,0 +1,18 @@ +export const Messages = { + form: { + fields: { + awsAccessKey: { + placeholder: 'Amazon RDS access key', + name: 'aws_access_key', + label: 'Amazon RDS access key', + }, + awsSecretKey: { + placeholder: 'Amazon RDS secret key', + name: 'aws_secret_key', + label: 'Amazon RDS secret key', + }, + }, + submitButton: 'Discover', + toMenuButton: 'Return to menu', + }, +}; diff --git a/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.styles.ts b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.styles.ts new file mode 100644 index 0000000000000..69e67d831dbeb --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.styles.ts @@ -0,0 +1,41 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ breakpoints, spacing }: GrafanaTheme) => { + const mq = `@media (max-width: ${breakpoints.md})`; + + return { + instanceForm: css` + padding: 0px; + margin-bottom: ${spacing.sm}; + width: 100%; + `, + buttonsWrapper: css` + display: flex; + width: 100%; + `, + fieldsWrapper: css` + display: flex; + width: 100%; + div { + width: 100%; + } + div:first-child { + margin-right: ${spacing.md}; + } + ${mq} { + flex-direction: column; + div:first-child { + margin-right: unset; + } + } + `, + credentialsField: css` + width: 48%; + `, + credentialsSubmit: css` + margin-left: ${spacing.md}; + margin-right: ${spacing.sm}; + `, + }; +}; diff --git a/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.test.tsx b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.test.tsx new file mode 100644 index 0000000000000..faea3cf73b3b2 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Credentials from './Credentials'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('Credentials:: ', () => { + it('should render access and secret keys fields', () => { + render(); + + expect(screen.getByTestId('aws_access_key-text-input')).toBeInTheDocument(); + expect(screen.getByTestId('aws_secret_key-password-input')).toBeInTheDocument(); + }); + + it('should call discover on submit', () => { + const discover = jest.fn(); + render(); + + const form = screen.getByTestId('credentials-form'); + fireEvent.submit(form); + + expect(discover).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.tsx b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.tsx new file mode 100644 index 0000000000000..36f91b654d3dd --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.tsx @@ -0,0 +1,53 @@ +import { Form as FormFinal } from 'react-final-form'; +import React, { FC, useCallback } from 'react'; +import { TextInputField, PasswordInputField } from '@percona/platform-core'; +import { Button, useStyles } from '@grafana/ui'; +import { getStyles } from './Credentials.styles'; +import { Messages } from './Credentials.messages'; +import { CredentialsProps, RDSCredentialsForm } from './Credentials.types'; + +const Credentials: FC = ({ discover, selectInstance }) => { + const styles = useStyles(getStyles); + + const onSubmit = useCallback( + (values: RDSCredentialsForm) => { + discover(values); + }, + [discover] + ); + + return ( + ( +
    +
    + + +
    +
    + + +
    +
    + + )} + /> + ); +}; + +export default Credentials; diff --git a/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.types.ts b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.types.ts new file mode 100644 index 0000000000000..20895a9ad2dde --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Credentials/Credentials.types.ts @@ -0,0 +1,11 @@ +import { SelectInstance } from 'app/percona/add-instance/panel.types'; + +export interface RDSCredentialsForm { + aws_access_key: string; + aws_secret_key: string; +} + +export interface CredentialsProps { + discover: (credentials: RDSCredentialsForm) => void; + selectInstance: SelectInstance; +} diff --git a/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.constants.ts b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.constants.ts new file mode 100644 index 0000000000000..56931723f88b9 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.constants.ts @@ -0,0 +1,4 @@ +export const SECURITY_CREDENTIALS_DOC_LINK = + 'https://www.percona.com/doc/percona-monitoring-and-management/2.x/setting-up/client/aws.html#creating-an-iam-user-with-permission-to-access-amazon-rds-db-instances'; +export const IAM_ROLE_DOC_LINK = + 'https://www.percona.com/doc/percona-monitoring-and-management/2.x/setting-up/client/aws.html#creating-an-iam-role'; diff --git a/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.messages.ts b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.messages.ts new file mode 100644 index 0000000000000..16d13b9c9b9bb --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + credentialsDocLink: 'Where do I get the security credentials for my Amazon RDS DB instances', + iamRoleDocLink: 'How to configure IAM role to automatically discover Amazon RDS DB instances', +}; diff --git a/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.styles.ts b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.styles.ts new file mode 100644 index 0000000000000..720b7f181b364 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.styles.ts @@ -0,0 +1,23 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ colors, spacing }: GrafanaTheme) => ({ + infoWrapper: css` + display: flex; + flex-direction: column; + background-color: ${colors.bg2}; + padding: ${spacing.sm}; + margin-top: ${spacing.md}; + margin-bottom: ${spacing.md}; + button { + height: 100%; + span { + white-space: break-spaces; + } + } + `, + infoItems: css` + list-style-position: inside; + margin-left: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.test.tsx b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.test.tsx new file mode 100644 index 0000000000000..99087b3bda401 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { DiscoveryDocs } from './DiscoveryDocs'; +import { render, screen } from '@testing-library/react'; + +describe('DiscoveryDocs:: ', () => { + it('should render list with two buttons for the docs', () => { + render(); + + expect(screen.getAllByRole('button')).toHaveLength(2); + }); +}); diff --git a/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.tsx b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.tsx new file mode 100644 index 0000000000000..c89e76ac27ab9 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/DiscoveryDocs/DiscoveryDocs.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; +import { Button, useStyles } from '@grafana/ui'; +import { getStyles } from './DiscoveryDocs.styles'; +import { Messages } from './DiscoveryDocs.messages'; +import { IAM_ROLE_DOC_LINK, SECURITY_CREDENTIALS_DOC_LINK } from './DiscoveryDocs.constants'; + +export const DiscoveryDocs: FC = () => { + const styles = useStyles(getStyles); + + return ( +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    + ); +}; diff --git a/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.styles.ts b/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.styles.ts new file mode 100644 index 0000000000000..ce39dc90ab6c5 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.styles.ts @@ -0,0 +1,12 @@ +import { css } from '@emotion/css'; + +export const styles = { + tableWrapper: css` + margin-top: 10px; + width: 100%; + `, + actionButtonWrapper: css` + diplay: flex; + justify-content: center; + `, +}; diff --git a/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.tsx b/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.tsx new file mode 100644 index 0000000000000..16c5b446f5bb1 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { Table } from 'app/percona/shared/components/Elements/Table/Table'; +import { styles } from './Instances.styles'; +import { InstancesTableProps } from './Instances.types'; +import { getInstancesColumns } from './InstancesColumns'; + +const Instances: FC = (props) => { + const { instances, selectInstance, credentials, loading } = props; + + const columns = getInstancesColumns(credentials, selectInstance); + + return ( +
    +
    + + ); +}; + +export default Instances; diff --git a/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.types.ts b/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.types.ts new file mode 100644 index 0000000000000..5c3667ebabad1 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Instances/Instances.types.ts @@ -0,0 +1,15 @@ +import { Instance } from '../../Discovery.types'; +import { SelectInstance } from 'app/percona/add-instance/panel.types'; +import { RDSCredentialsForm } from '../Credentials/Credentials.types'; + +export type RDSCredentials = RDSCredentialsForm & { + isRDS?: boolean; + qan_postgresql_pgstatements?: boolean; +}; + +export interface InstancesTableProps { + instances: Instance[]; + selectInstance: SelectInstance; + loading: boolean; + credentials: RDSCredentialsForm; +} diff --git a/public/app/percona/add-instance/components/Discovery/components/Instances/InstancesColumns.tsx b/public/app/percona/add-instance/components/Discovery/components/Instances/InstancesColumns.tsx new file mode 100644 index 0000000000000..b4ae59cdc2c14 --- /dev/null +++ b/public/app/percona/add-instance/components/Discovery/components/Instances/InstancesColumns.tsx @@ -0,0 +1,75 @@ +/* eslint-disable react/display-name */ +import { Button } from '@grafana/ui'; +import React from 'react'; +import { SelectInstance } from 'app/percona/add-instance/panel.types'; +import { DATABASE_LABELS, Databases } from 'app/percona/shared/core'; +import { styles } from './Instances.styles'; +import { Instance } from '../../Discovery.types'; +import { RDSCredentialsForm } from '../Credentials/Credentials.types'; + +const getEngineType = (type?: string) => { + switch (type) { + case 'DISCOVER_RDS_MYSQL': + return DATABASE_LABELS[Databases.mysql]; + case 'DISCOVER_RDS_POSTGRESQL': + return DATABASE_LABELS[Databases.postgresql]; + case 'DISCOVER_RDS_INVALID': + return 'Unknown type'; + default: + return 'Unknown type'; + } +}; + +const getDatabaseType = (type?: string) => { + switch (type) { + case 'DISCOVER_RDS_MYSQL': + return Databases.mysql; + case 'DISCOVER_RDS_POSTGRESQL': + return Databases.postgresql; + default: + return ''; + } +}; + +export const getInstancesColumns = (credentials: RDSCredentialsForm, onSelectInstance: SelectInstance) => [ + { + Header: 'Region', + accessor: 'region', + }, + { + Header: 'Availability Zone', + accessor: 'az', + }, + { + Header: 'Engine', + accessor: (element: Instance) => + element.engine ? `${getEngineType(element.engine)} ${element.engine_version}` : 'nothing', + }, + { + Header: 'Instance ID', + accessor: 'instance_id', + }, + { + Header: 'Address', + accessor: (element: Instance) => element.address.split(':')[0], + }, + { + Header: 'Action', + accessor: (element: Instance) => { + const selectionHandler = () => { + onSelectInstance({ + type: getDatabaseType(element.engine), + credentials: { ...{ ...element, ...credentials }, isRDS: true }, + }); + }; + + return ( +
    + +
    + ); + }, + }, +]; diff --git a/public/app/percona/add-instance/panel.styles.ts b/public/app/percona/add-instance/panel.styles.ts new file mode 100644 index 0000000000000..e89b534c2ad12 --- /dev/null +++ b/public/app/percona/add-instance/panel.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { spacing } }: GrafanaTheme2) => ({ + content: css` + display: flex; + flex-direction: column; + align-content: center; + align-items: center; + width: 100%; + `, + returnButton: css` + align-self: flex-start; + margin-bottom: ${spacing.md}; + `, +}); diff --git a/public/app/percona/add-instance/panel.tsx b/public/app/percona/add-instance/panel.tsx new file mode 100644 index 0000000000000..25153813a2359 --- /dev/null +++ b/public/app/percona/add-instance/panel.tsx @@ -0,0 +1,80 @@ +/* eslint-disable react/display-name */ +import React, { useMemo, useState } from 'react'; +import { Button, useStyles2 } from '@grafana/ui'; +import { useSelector } from 'react-redux'; +import { cx } from '@emotion/css'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { getPerconaSettings } from 'app/percona/shared/core/selectors'; +import AddRemoteInstance from './components/AddRemoteInstance/AddRemoteInstance'; +import Discovery from './components/Discovery/Discovery'; +import AzureDiscovery from './components/AzureDiscovery/Discovery'; +import { AddInstance } from './components/AddInstance/AddInstance'; +import { getStyles } from './panel.styles'; +import { Messages } from './components/AddRemoteInstance/AddRemoteInstance.messages'; +import { InstanceTypesExtra, InstanceAvailable, AvailableTypes } from './panel.types'; +import { Databases } from '../../percona/shared/core'; +import { FeatureLoader } from '../shared/components/Elements/FeatureLoader'; + +const availableInstanceTypes: AvailableTypes[] = [ + InstanceTypesExtra.rds, + InstanceTypesExtra.azure, + Databases.postgresql, + Databases.mysql, + Databases.proxysql, + Databases.mongodb, + InstanceTypesExtra.external, + Databases.haproxy, +]; + +const AddInstancePanel = () => { + const styles = useStyles2(getStyles); + const { result: settings } = useSelector(getPerconaSettings); + const { azureDiscoverEnabled } = settings!; + const instanceType = ''; + const [selectedInstance, selectInstance] = useState({ + type: availableInstanceTypes.includes(instanceType as AvailableTypes) ? instanceType : '', + }); + const navModel = usePerconaNavModel('add-instance'); + + const InstanceForm = useMemo( + () => () => ( + <> + {selectedInstance.type !== InstanceTypesExtra.rds && selectedInstance.type !== InstanceTypesExtra.azure && ( + + )} + {selectedInstance.type === InstanceTypesExtra.rds && } + {selectedInstance.type === InstanceTypesExtra.azure && } + {selectedInstance.type !== InstanceTypesExtra.rds && selectedInstance.type !== InstanceTypesExtra.azure && ( + + )} + + ), + [selectedInstance, styles.returnButton] + ); + + return ( + + + +
    + {!selectedInstance.type ? ( + + ) : ( + + )} +
    +
    +
    +
    + ); +}; + +export default AddInstancePanel; diff --git a/public/app/percona/add-instance/panel.types.ts b/public/app/percona/add-instance/panel.types.ts new file mode 100644 index 0000000000000..449196578a5a3 --- /dev/null +++ b/public/app/percona/add-instance/panel.types.ts @@ -0,0 +1,71 @@ +import { Databases } from '../../percona/shared/core'; +import { AzureCredentials } from './components/AzureDiscovery/components/Instances/Instances.types'; +import { RDSCredentials } from './components/Discovery/components/Instances/Instances.types'; + +export interface RemoteInstanceCredentials { + serviceName?: string; + username?: string; + port?: string; + address?: string; + isRDS?: boolean; + isAzure?: boolean; + region?: string; + aws_access_key?: string; + aws_secret_key?: string; + azure_client_id?: string; + azure_client_secret?: string; + azure_tenant_id?: string; + azure_subscription_id?: string; + azure_resource_group?: string; + instance_id?: string; + azure_database_exporter?: boolean; + az?: string; + qan?: boolean; + type?: string; + node_model?: string; + tablestats_group_table_limit?: number; + disable_query_examples?: boolean; + node_name?: string; + password?: string; + environment?: string; + custom_labels?: {}; + skip_connection_check?: boolean; + tls?: boolean; + tls_skip_verify?: boolean; +} + +export enum InstanceTypesExtra { + rds = 'rds', + azure = 'azure', + external = 'external', +} + +export type InstanceTypes = Databases | InstanceTypesExtra; + +export type AvailableTypes = Exclude; + +export const INSTANCE_TYPES_LABELS = { + [Databases.mysql]: 'MySQL', + [Databases.mariadb]: 'MariaDB', + [Databases.mongodb]: 'MongoDB', + [Databases.postgresql]: 'PostgreSQL', + [Databases.proxysql]: 'ProxySQL', + [Databases.haproxy]: 'HAProxy', + [InstanceTypesExtra.azure]: '', + [InstanceTypesExtra.rds]: '', + [InstanceTypesExtra.external]: '', +}; + +export type InstanceAvailableType = AvailableTypes | ''; + +export interface InstanceAvailable { + type: InstanceAvailableType; + credentials?: AzureCredentials | RDSCredentials | RemoteInstanceCredentials; +} + +export interface Instance { + type: InstanceTypes | ''; + credentials?: AzureCredentials | RDSCredentials | RemoteInstanceCredentials; +} + +export type SelectInstance = (instance: InstanceAvailable) => void; diff --git a/public/app/percona/backup/Backup.messages.ts b/public/app/percona/backup/Backup.messages.ts new file mode 100644 index 0000000000000..4b0cbff712c98 --- /dev/null +++ b/public/app/percona/backup/Backup.messages.ts @@ -0,0 +1,110 @@ +export const Messages = { + tabs: { + inventory: 'Backup Inventory', + scheduled: 'Scheduled Backups', + locations: 'Storage Locations', + restore: 'Restore History', + }, + add: 'Add', + backupManagement: 'Backup Management', + backupInventory: { + table: { + noData: 'No backups found', + columns: { + name: 'Backup name', + created: 'Created', + location: 'Location', + vendor: 'Vendor', + status: 'Status', + actions: 'Actions', + type: 'Type', + }, + status: { + invalid: 'Invalid', + pending: 'Pending', + inProgress: 'In progress', + paused: 'Paused', + success: 'Success', + error: 'Error', + }, + dataModel: { + invalid: 'Invalid', + physical: 'Physical', + logical: 'Logical', + }, + actions: 'Actions', + }, + deleteModalTitle: 'Delete backup artifact', + deleteFromStorage: 'Delete from storage', + getLogsTitle: (name: string) => `Backup logs for ${name}`, + getDeleteMessage: (name: string) => `Are you sure you want to delete "${name}"?`, + }, + restoreHistory: { + table: { + noData: 'No restores found', + columns: { + started: 'Started at', + }, + }, + }, + storageLocations: { + table: { + noData: 'No storage locations found', + columns: { + name: 'Name', + type: 'Type', + path: 'Endpoint or path', + labels: 'Labels', + actions: 'Actions', + }, + }, + addSuccess: 'Backup location was successfully added', + testSuccess: 'This storage location is valid', + editSuccess: (name: string) => `Backup location "${name}" was successfully updated`, + getDeleteSuccess: (name: string) => `Backup location "${name}" successfully deleted.`, + }, + scheduledBackups: { + table: { + noData: 'No scheduled backups found', + columns: { + name: 'Name', + vendor: 'Vendor', + start: 'Start at', + retention: 'Retention', + frequency: 'Frequency', + location: 'Location', + lastBackup: 'Last backup (local time)', + type: 'Type', + actions: 'Actions', + }, + }, + deleteModalTitle: 'Delete scheduled backup', + copyOf: 'Copy of', + addSuccess: 'Backup successfully scheduled', + unlimited: 'Unlimited', + getEditSuccess: (name: string) => `Scheduled backup "${name}" successfully updated`, + getDeleteSuccess: (name: string) => `Scheduled backup "${name}" successfully deleted.`, + getDeleteMessage: (name: string) => `Are you sure you want to delete the scheduled backup "${name}"?`, + }, + status: { + invalid: 'Invalid', + pending: 'Pending', + inProgress: 'In progress', + deleting: 'Deleting', + paused: 'Paused', + success: 'Success', + error: 'Error', + failedToDelete: 'Failed to delete', + }, + dataModel: { + invalid: 'Invalid', + physical: 'Physical', + logical: 'Logical', + }, + backupMode: { + full: 'Full', + incremental: 'Incremental', + pitr: 'PITR', + invalid: 'Invalid', + }, +}; diff --git a/public/app/percona/backup/Backup.types.ts b/public/app/percona/backup/Backup.types.ts new file mode 100644 index 0000000000000..634dd28107c22 --- /dev/null +++ b/public/app/percona/backup/Backup.types.ts @@ -0,0 +1,55 @@ +export enum DataModel { + DATA_MODEL_INVALID = 'DATA_MODEL_INVALID', + PHYSICAL = 'PHYSICAL', + LOGICAL = 'LOGICAL', +} + +export enum BackupStatus { + BACKUP_STATUS_INVALID = 'BACKUP_STATUS_INVALID', + BACKUP_STATUS_PENDING = 'BACKUP_STATUS_PENDING', + BACKUP_STATUS_IN_PROGRESS = 'BACKUP_STATUS_IN_PROGRESS', + BACKUP_STATUS_PAUSED = 'BACKUP_STATUS_PAUSED', + BACKUP_STATUS_SUCCESS = 'BACKUP_STATUS_SUCCESS', + BACKUP_STATUS_ERROR = 'BACKUP_STATUS_ERROR', + BACKUP_STATUS_DELETING = 'BACKUP_STATUS_DELETING', + BACKUP_STATUS_FAILED_TO_DELETE = 'BACKUP_STATUS_FAILED_TO_DELETE', +} + +export enum RestoreStatus { + RESTORE_STATUS_INVALID = 'RESTORE_STATUS_INVALID', + RESTORE_STATUS_IN_PROGRESS = 'RESTORE_STATUS_IN_PROGRESS', + RESTORE_STATUS_SUCCESS = 'RESTORE_STATUS_SUCCESS', + RESTORE_STATUS_ERROR = 'RESTORE_STATUS_ERROR', +} + +export enum RetryMode { + AUTO = 'AUTO', + MANUAL = 'MANUAL', +} + +export enum BackupMode { + INVALID = 'BACKUP_MODE_INVALID', + SNAPSHOT = 'SNAPSHOT', + INCREMENTAL = 'INCREMENTAL', + PITR = 'PITR', +} + +export interface RawBackupLog { + chunk_id: number; + data: string; + time: string; +} + +export interface BackupLogResponse { + logs: RawBackupLog[]; + end: boolean; +} + +export interface BackupLogChunk extends Omit { + id: number; +} + +export interface BackupLogs { + logs: BackupLogChunk[]; + end: boolean; +} diff --git a/public/app/percona/backup/Backup.utils.test.ts b/public/app/percona/backup/Backup.utils.test.ts new file mode 100644 index 0000000000000..cef52612c6c5f --- /dev/null +++ b/public/app/percona/backup/Backup.utils.test.ts @@ -0,0 +1,32 @@ +import { DataModel, BackupStatus, RestoreStatus, BackupMode } from './Backup.types'; +import { formatStatus, formatDataModel, formatBackupMode } from './Backup.utils'; +import { Messages } from './Backup.messages'; + +const { status: statusMsg, dataModel: dataModelMsg, backupMode: backupModeMsg } = Messages; + +describe('Backup::utils', () => { + describe('formatStatus', () => { + it('should correctly format status', () => { + expect(formatStatus(BackupStatus.BACKUP_STATUS_INVALID)).toBe(statusMsg.invalid); + expect(formatStatus(RestoreStatus.RESTORE_STATUS_IN_PROGRESS)).toBe(statusMsg.inProgress); + expect(formatStatus(RestoreStatus.RESTORE_STATUS_ERROR)).toBe(statusMsg.error); + expect(formatStatus('bla' as RestoreStatus)).toBe(''); + }); + }); + + describe('formatDataModel', () => { + it('should correctly format status', () => { + expect(formatDataModel(DataModel.DATA_MODEL_INVALID)).toBe(dataModelMsg.invalid); + expect(formatDataModel(DataModel.LOGICAL)).toBe(dataModelMsg.logical); + expect(formatDataModel(DataModel.PHYSICAL)).toBe(dataModelMsg.physical); + expect(formatDataModel('bla' as DataModel)).toBe(''); + }); + }); + + describe('formatBackupMode', () => { + it('should correctly format backup mode', () => { + expect(formatBackupMode(BackupMode.INVALID)).toBe(backupModeMsg.invalid); + expect(formatBackupMode('bla' as BackupMode)).toBe(backupModeMsg.invalid); + }); + }); +}); diff --git a/public/app/percona/backup/Backup.utils.ts b/public/app/percona/backup/Backup.utils.ts new file mode 100644 index 0000000000000..0a2bb60c41caa --- /dev/null +++ b/public/app/percona/backup/Backup.utils.ts @@ -0,0 +1,44 @@ +import { Messages } from './Backup.messages'; +import { DataModel, RestoreStatus, BackupStatus, BackupMode } from './Backup.types'; + +const { status: statusMsg, dataModel: dataModelMsg, backupMode: backupModeMsg } = Messages; + +export const formatStatus = (status: BackupStatus | RestoreStatus): string => { + const map: Record = { + [BackupStatus.BACKUP_STATUS_INVALID]: statusMsg.invalid, + [RestoreStatus.RESTORE_STATUS_INVALID]: statusMsg.invalid, + [BackupStatus.BACKUP_STATUS_PENDING]: statusMsg.pending, + [BackupStatus.BACKUP_STATUS_IN_PROGRESS]: statusMsg.inProgress, + [BackupStatus.BACKUP_STATUS_DELETING]: statusMsg.deleting, + [BackupStatus.BACKUP_STATUS_FAILED_TO_DELETE]: statusMsg.failedToDelete, + [RestoreStatus.RESTORE_STATUS_IN_PROGRESS]: statusMsg.inProgress, + [BackupStatus.BACKUP_STATUS_PAUSED]: statusMsg.paused, + [BackupStatus.BACKUP_STATUS_SUCCESS]: statusMsg.success, + [RestoreStatus.RESTORE_STATUS_SUCCESS]: statusMsg.success, + [BackupStatus.BACKUP_STATUS_ERROR]: statusMsg.error, + [RestoreStatus.RESTORE_STATUS_ERROR]: statusMsg.error, + }; + + return map[status] ?? ''; +}; + +export const formatDataModel = (model: DataModel): string => { + const map: Record = { + [DataModel.DATA_MODEL_INVALID]: dataModelMsg.invalid, + [DataModel.PHYSICAL]: dataModelMsg.physical, + [DataModel.LOGICAL]: dataModelMsg.logical, + }; + + return map[model] ?? ''; +}; + +export const formatBackupMode = (mode: BackupMode): string => { + const map: Record = { + [BackupMode.SNAPSHOT]: backupModeMsg.full, + [BackupMode.INCREMENTAL]: backupModeMsg.incremental, + [BackupMode.PITR]: backupModeMsg.pitr, + [BackupMode.INVALID]: backupModeMsg.invalid, + }; + + return map[mode] || map[BackupMode.INVALID]; +}; diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.constants.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.constants.ts new file mode 100644 index 0000000000000..4d753846b1d9e --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.constants.ts @@ -0,0 +1,76 @@ +import { SelectableValue } from '@grafana/data'; +import { DataModel, RetryMode } from 'app/percona/backup/Backup.types'; +import { Databases, DATABASE_LABELS } from 'app/percona/shared/core'; +import { MONTHS, WEEKDAYS } from 'app/percona/shared/helpers/cron/constants'; +import { getOptionFromDigit } from './AddBackupModal.utils'; + +export const VENDOR_OPTIONS: Array> = [ + { + value: Databases.mysql, + label: DATABASE_LABELS.mysql, + }, + { + value: Databases.mongodb, + label: DATABASE_LABELS.mongodb, + }, + { + value: Databases.postgresql, + label: DATABASE_LABELS.postgresql, + }, + { + value: Databases.proxysql, + label: DATABASE_LABELS.proxysql, + }, +]; + +export const DATA_MODEL_OPTIONS: Array> = [ + { + value: DataModel.PHYSICAL, + label: 'Physical', + }, + { + value: DataModel.LOGICAL, + label: 'Logical', + }, +]; + +export const RETRY_MODE_OPTIONS: Array> = [ + { + value: RetryMode.AUTO, + label: 'Auto', + }, + { + value: RetryMode.MANUAL, + label: 'Manual', + }, +]; + +export const MONTH_OPTIONS: Array> = MONTHS.map((month, idx) => ({ + value: idx + 1, + label: `${month[0].toUpperCase()}${month.substr(1).toLowerCase()}`, +})); + +export const DAY_OPTIONS: Array> = Array.from(Array(31).keys()).map((value) => + getOptionFromDigit(value + 1) +); + +export const WEEKDAY_OPTIONS: Array> = WEEKDAYS.map((day, idx) => ({ + value: idx, + label: `${day[0].toUpperCase()}${day.substr(1).toLowerCase()}`, +})); + +export const HOUR_OPTIONS: Array> = Array.from(Array(24).keys()).map((value) => + getOptionFromDigit(value) +); + +export const MINUTE_OPTIONS: Array> = Array.from(Array(60).keys()).map((value) => + getOptionFromDigit(value) +); + +export const MAX_VISIBLE_OPTIONS = 4; + +export const MIN_RETENTION = 0; + +export const MAX_RETENTION = 99; + +export const MAX_BACKUP_NAME = 100; diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.messages.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.messages.ts new file mode 100644 index 0000000000000..d2de346bc70ee --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.messages.ts @@ -0,0 +1,37 @@ +export const Messages = { + serviceName: 'Service name', + vendor: 'Vendor', + dataModel: 'Data model', + type: 'Backup type', + databases: 'Databases', + backupName: 'Backup name', + description: 'Description', + location: 'Location', + retryMode: 'Retry mode', + cancelAction: 'Cancel', + retryTimes: 'Retry, times', + retryInterval: 'Retry interval, seconds', + every: 'Every', + month: 'Month', + day: 'Day', + weekDay: 'Weekday', + startTime: 'Start time, h/m', + fullLogs: 'Full logs', + enabled: 'Enabled', + retention: 'Retention (Number of backups - 0 for unlimited)', + scheduleSection: 'Schedule - UTC time', + getModalTitle: (scheduleMode: boolean, edit: boolean) => { + if (scheduleMode) { + return edit ? 'Edit Scheduled backup' : 'Schedule backup'; + } + + return 'Backup on demand'; + }, + getSubmitButtonText: (scheduleMode: boolean, edit: boolean) => { + if (scheduleMode) { + return edit ? 'Edit' : 'Schedule'; + } + + return 'Backup'; + }, +}; diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.service.test.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.service.test.ts new file mode 100644 index 0000000000000..ca96cced71f52 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.service.test.ts @@ -0,0 +1,32 @@ +import { AddBackupModalService } from './AddBackupModal.service'; +import { SelectableValue } from '@grafana/data'; +import { Databases } from 'app/percona/shared/core'; +import { SelectableService } from './AddBackupModal.types'; +import { InventoryService } from 'app/percona/inventory/Inventory.service'; + +describe('AddBackupModalService', () => { + it('should return only supported services', async () => { + jest.spyOn(InventoryService, 'getDbServices').mockReturnValueOnce( + Promise.resolve({ + postgresql: [{ id: 'psql1', name: 'postgres one' }], + mongodb: [ + { id: 'mongo1', name: 'mongo one' }, + { id: 'mongo2', name: 'mongo two' }, + ], + mysql: [{ id: 'mysql1', name: 'mysql one' }], + proxysql: [{ id: 'proxysql1', name: 'proxysql one' }], + }) + ); + const services = await AddBackupModalService.loadServiceOptions(); + const orderFn = (s1: SelectableValue, s2: SelectableValue) => + s1.label?.localeCompare(s2.label ?? '') ?? 0; + + expect(services.sort(orderFn)).toEqual>>( + [ + { label: 'mysql one', value: { id: 'mysql1', vendor: Databases.mysql } }, + { label: 'mongo one', value: { id: 'mongo1', vendor: Databases.mongodb } }, + { label: 'mongo two', value: { id: 'mongo2', vendor: Databases.mongodb } }, + ].sort(orderFn) + ); + }); +}); diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.service.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.service.ts new file mode 100644 index 0000000000000..f844f7143e3de --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.service.ts @@ -0,0 +1,35 @@ +import { SelectableValue } from '@grafana/data'; +import { InventoryService } from 'app/percona/inventory/Inventory.service'; +import { Databases } from 'app/percona/shared/core'; +import { StorageLocationsService } from '../StorageLocations/StorageLocations.service'; +import { SelectableService } from './AddBackupModal.types'; + +export const AddBackupModalService = { + async loadServiceOptions(): Promise>> { + const supportedServices: Databases[] = [Databases.mysql, Databases.mongodb]; + const services = await InventoryService.getDbServices(); + const result: Array> = []; + + Object.keys(services).forEach((serviceName) => { + const newServices = services[serviceName as Databases] ?? []; + + if (supportedServices.includes(serviceName as Databases)) { + result.push( + ...newServices.map( + ({ id, name }): SelectableValue => ({ + label: name, + value: { id, vendor: serviceName as Databases }, + }) + ) + ); + } + }); + + return result; + }, + async loadLocationOptions(): Promise>> { + const { locations = [] } = await StorageLocationsService.list(); + + return locations.map(({ location_id, name }): SelectableValue => ({ label: name, value: location_id })); + }, +}; diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.styles.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.styles.ts new file mode 100644 index 0000000000000..f1f3793203102 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.styles.ts @@ -0,0 +1,84 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ colors, typography, spacing, border }: GrafanaTheme) => ({ + retryFields: css` + display: flex; + `, + retrySelect: css` + flex: 1 1 50%; + + &:first-child { + padding-right: ${spacing.sm}; + } + + &:last-child { + padding-left: ${spacing.sm}; + } + `, + formContainer: css` + display: flex; + flex-wrap: wrap; + `, + formHalf: css` + flex: 0 0 50%; + + &:first-child { + padding-right: ${spacing.md}; + } + + &:last-child { + padding-left: ${spacing.md}; + } + `, + advancedGroup: css` + border: ${border.width.sm} solid ${colors.formInputBorder}; + border-radius: ${border.radius.sm}; + padding: ${spacing.md}; + margin-bottom: ${spacing.formInputMargin}; + position: relative; + `, + advancedTitle: css` + color: ${colors.formLabel}; + font-weight: ${typography.weight.semibold}; + position: absolute; + top: -8px; + padding: 0 ${spacing.xs}; + background-color: ${colors.bodyBg}; + `, + advancedRow: css` + display: flex; + max-width: 400px; + margin: 0 auto; + + & > * { + flex: 1 0 50%; + + &:first-child { + padding-right: ${spacing.sm}; + } + + &:last-child { + padding-left: ${spacing.sm}; + } + + &:first-child:last-child { + padding-right: 0; + padding-left: 0; + } + } + `, + checkbox: css` + &:not(:last-child) { + margin-bottom: 0; + } + + & > div:last-child { + // we don't need the error line in this case + display: none; + } + `, + apiErrorSection: css` + margin-bottom: ${spacing.md}; + `, +}); diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.test.tsx b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.test.tsx new file mode 100644 index 0000000000000..25ab16df57347 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AddBackupModal } from './AddBackupModal'; + +jest.mock('./AddBackupModal.service'); + +jest.mock('app/percona/shared/components/Form/SelectField', () => ({ + SelectField: jest.fn(() =>
    ), +})); + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + AsyncSelect: jest.fn(() =>
    ), +})); + +describe('AddBackupModal', () => { + it('should render fields', () => { + render(); + + expect(screen.getAllByTestId('async-select')).toHaveLength(2); + const textboxes = screen.getAllByRole('textbox'); + expect(textboxes.filter((textbox) => textbox.tagName === 'INPUT')).toHaveLength(2); + expect(textboxes.filter((textbox) => textbox.tagName === 'TEXTAREA')).toHaveLength(1); + expect(screen.queryByTestId('advanced-backup-fields')).not.toBeInTheDocument(); + expect(screen.queryByTestId('retry-mode-selector')).toBeInTheDocument(); + expect(screen.queryAllByText('Incremental')).toHaveLength(0); + expect(screen.queryAllByText('Full')).toHaveLength(0); + }); + + it('should render advanced fields when in schedule mode', () => { + render(); + + expect(screen.getByTestId('advanced-backup-fields')).toBeInTheDocument(); + expect(screen.getByTestId('select-field')).toBeInTheDocument(); + expect(screen.getByTestId('multi-select-field-div-wrapper').children).not.toHaveLength(0); + expect(screen.queryByTestId('retry-mode-selector')).toBeInTheDocument(); + }); + + it('should render backup mode selector when in schedule mode', () => { + render(); + expect(screen.queryByText('Incremental')).toBeInTheDocument(); + expect(screen.queryByText('Full')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.tsx b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.tsx new file mode 100644 index 0000000000000..8a785db8ce24b --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.tsx @@ -0,0 +1,286 @@ +import React, { FC, useMemo } from 'react'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { + CheckboxField, + LoaderButton, + Modal, + NumberInputField, + RadioButtonGroupField, + TextareaInputField, + TextInputField, + validators, +} from '@percona/platform-core'; +import { Field, withTypes } from 'react-final-form'; +import { SelectableValue } from '@grafana/data'; +import { AddBackupFormProps, AddBackupModalProps, SelectableService } from './AddBackupModal.types'; +import { RetryModeSelector } from './RetryModeSelector'; +import { validators as customValidators } from 'app/percona/shared/helpers/validators'; +import { Messages } from './AddBackupModal.messages'; +import { + toFormBackup, + isCronFieldDisabled, + PERIOD_OPTIONS, + getBackupModeOptions, + getDataModelFromVendor, +} from './AddBackupModal.utils'; +import { AddBackupModalService } from './AddBackupModal.service'; +import { Databases, DATABASE_LABELS } from 'app/percona/shared/core'; +import { AsyncSelectField } from 'app/percona/shared/components/Form/AsyncSelectField'; +import { + DATA_MODEL_OPTIONS, + DAY_OPTIONS, + HOUR_OPTIONS, + MAX_RETENTION, + MAX_VISIBLE_OPTIONS, + MINUTE_OPTIONS, + MIN_RETENTION, + MONTH_OPTIONS, + WEEKDAY_OPTIONS, + MAX_BACKUP_NAME, +} from './AddBackupModal.constants'; +import { getStyles } from './AddBackupModal.styles'; +import { SelectField } from 'app/percona/shared/components/Form/SelectField'; +import { MultiSelectField } from 'app/percona/shared/components/Form/MultiSelectField'; +import { BackupMode } from '../../Backup.types'; +import { BackupErrorSection } from '../BackupErrorSection/BackupErrorSection'; + +export const AddBackupModal: FC = ({ + backup, + isVisible, + scheduleMode = false, + backupErrors = [], + onClose, + onBackup, +}) => { + const styles = useStyles(getStyles); + const initialValues = useMemo(() => toFormBackup(backup), [backup]); + const { Form } = withTypes(); + + const handleSubmit = (values: AddBackupFormProps) => + onBackup({ + ...values, + retention: parseInt(`${values.retention}`, 10), + retryTimes: parseInt(`${values.retryTimes}`, 10), + }); + + return ( + +
    { + tools.changeValue(state, 'vendor', () => vendor); + tools.changeValue(state, 'dataModel', () => getDataModelFromVendor(vendor)); + //TODO remove this when we support incremental backups for MySQL + if (vendor === Databases.mysql) { + tools.changeValue(state, 'mode', () => BackupMode.SNAPSHOT); + } + }, + }} + render={({ handleSubmit, valid, pristine, submitting, values, form }) => ( + +
    +
    + + {({ input }) => ( +
    + ) => { + input.onChange(service); + form.mutators.changeVendor(service.value!.vendor); + }} + data-testid="service-select-input" + /> +
    + )} +
    + DATABASE_LABELS[vendor as Databases] || ''} + /> +
    +
    + + + {({ input }) => ( +
    + +
    + )} +
    +
    +
    + + {scheduleMode && ( + + )} + {!scheduleMode && } + + {scheduleMode && ( +
    +
    {Messages.scheduleSection}
    +
    +
    + + {({ input }) => ( +
    + +
    + )} +
    + + {({ input }) => ( +
    + +
    + )} +
    +
    +
    + + {({ input }) => ( +
    + +
    + )} +
    + + {({ input }) => ( +
    + +
    + )} +
    +
    +
    + + {({ input }) => ( +
    + +
    + )} +
    + + {({ input }) => ( +
    + +
    + )} +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + )} + {!!backupErrors.length && } + + + {Messages.getSubmitButtonText(scheduleMode, !!backup)} + + + + + )} + /> +
    + ); +}; diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.types.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.types.ts new file mode 100644 index 0000000000000..8d0f293ac0d50 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.types.ts @@ -0,0 +1,43 @@ +import { SelectableValue } from '@grafana/data'; +import { BackupMode, DataModel, RetryMode } from 'app/percona/backup/Backup.types'; +import { ApiVerboseError, Databases } from 'app/percona/shared/core'; +import { PeriodType } from 'app/percona/shared/helpers/cron/types'; +import { Backup } from '../BackupInventory/BackupInventory.types'; +import { ScheduledBackup } from '../ScheduledBackups/ScheduledBackups.types'; + +export interface AddBackupModalProps { + backup: Backup | ScheduledBackup | null; + isVisible: boolean; + scheduleMode?: boolean; + backupErrors?: ApiVerboseError[]; + onClose: () => void; + onBackup: (values: AddBackupFormProps) => void; +} + +export interface SelectableService { + vendor: Databases; + id: string; +} + +export interface AddBackupFormProps { + id: string; + service: SelectableValue | null; + dataModel: DataModel; + backupName: string; + description: string; + location: SelectableValue | null; + retention?: number; + retryMode?: RetryMode; + retryTimes?: number; + retryInterval?: number; + period?: SelectableValue; + month?: Array>; + day?: Array>; + weekDay?: Array>; + startHour?: Array>; + startMinute?: Array>; + logs?: boolean; + active?: boolean; + vendor: Databases | null; + mode: BackupMode; +} diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.utils.test.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.utils.test.ts new file mode 100644 index 0000000000000..8e900c7868424 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.utils.test.ts @@ -0,0 +1,188 @@ +import { stubs as backupStubs } from '../BackupInventory/__mocks__/BackupInventory.service'; +import { BackupMode, DataModel, RetryMode } from 'app/percona/backup/Backup.types'; +import { ScheduledBackup } from '../ScheduledBackups/ScheduledBackups.types'; +import { AddBackupFormProps } from './AddBackupModal.types'; +import { + toFormBackup, + isCronFieldDisabled, + getOptionFromPeriodType, + getOptionFromDigit, + getBackupModeOptions, + getDataModelFromVendor, +} from './AddBackupModal.utils'; +import { Databases } from 'app/percona/shared/core'; + +describe('AddBackupModal::utils', () => { + describe('toFormBackup', () => { + it('should return default values if a null Backup is passed', () => { + expect(toFormBackup(null)).toEqual({ + id: '', + service: null, + dataModel: DataModel.PHYSICAL, + backupName: '', + description: '', + location: null, + retryMode: RetryMode.MANUAL, + retryTimes: 2, + retryInterval: 30, + period: { value: 'year', label: 'Year' }, + month: [], + day: [], + weekDay: [], + startHour: [{ value: 0, label: '00' }], + startMinute: [{ value: 0, label: '00' }], + retention: 7, + logs: false, + active: true, + vendor: null, + mode: BackupMode.SNAPSHOT, + }); + }); + + it('should convert to form props', () => { + const backup = backupStubs[0]; + const { id, serviceName, serviceId, vendor, dataModel, locationName, locationId } = backup; + + expect(toFormBackup(backup)).toEqual({ + id, + service: { label: serviceName, value: { id: serviceId, vendor } }, + dataModel, + backupName: 'Backup 1', + description: '', + location: { label: locationName, value: locationId }, + vendor: Databases.mysql, + mode: BackupMode.SNAPSHOT, + retryInterval: 30, + retryMode: RetryMode.MANUAL, + retryTimes: 2, + }); + }); + + it('should correctly convert a scheduled backup', () => { + const backup: ScheduledBackup = { + id: 'backup_1', + name: 'Backup 1', + locationId: 'location_1', + locationName: 'Location 1', + serviceId: 'service_1', + serviceName: 'Service 1', + vendor: Databases.mongodb, + start: 1623584353170, + retention: 0, + cronExpression: '30 0 * * *', + lastBackup: 1623584353170, + dataModel: DataModel.PHYSICAL, + description: '', + mode: BackupMode.SNAPSHOT, + retryInterval: '10s', + retryTimes: 1, + enabled: true, + }; + + expect(toFormBackup(backup)).toEqual({ + id: backup.id, + service: { label: 'Service 1', value: { id: 'service_1', vendor: Databases.mongodb } }, + dataModel: DataModel.PHYSICAL, + backupName: 'Backup 1', + description: '', + location: { label: 'Location 1', value: 'location_1' }, + retryMode: RetryMode.AUTO, + retryTimes: 1, + retryInterval: 10, + period: { value: 'day', label: 'Day' }, + month: [], + day: [], + weekDay: [], + startHour: [{ value: 0, label: '00' }], + startMinute: [{ value: 30, label: '30' }], + retention: 0, + logs: false, + active: true, + vendor: Databases.mongodb, + mode: BackupMode.SNAPSHOT, + }); + }); + }); + + describe('isCronFieldDisabled', () => { + it('should not disabled any fiels for period = year', () => { + expect(isCronFieldDisabled('year', 'month')).toBeFalsy(); + expect(isCronFieldDisabled('year', 'day')).toBeFalsy(); + expect(isCronFieldDisabled('year', 'weekDay')).toBeFalsy(); + expect(isCronFieldDisabled('year', 'startHour')).toBeFalsy(); + expect(isCronFieldDisabled('year', 'startMinute')).toBeFalsy(); + }); + + it('should disable month for period = month', () => { + expect(isCronFieldDisabled('month', 'month')).toBeTruthy(); + expect(isCronFieldDisabled('month', 'day')).toBeFalsy(); + expect(isCronFieldDisabled('month', 'weekDay')).toBeFalsy(); + expect(isCronFieldDisabled('month', 'startHour')).toBeFalsy(); + expect(isCronFieldDisabled('month', 'startMinute')).toBeFalsy(); + }); + + it('should disable month, day for period = week', () => { + expect(isCronFieldDisabled('week', 'month')).toBeTruthy(); + expect(isCronFieldDisabled('week', 'day')).toBeTruthy(); + expect(isCronFieldDisabled('week', 'weekDay')).toBeFalsy(); + expect(isCronFieldDisabled('week', 'startHour')).toBeFalsy(); + expect(isCronFieldDisabled('week', 'startMinute')).toBeFalsy(); + }); + + it('should disable month, day, weekDay for period = day', () => { + expect(isCronFieldDisabled('day', 'month')).toBeTruthy(); + expect(isCronFieldDisabled('day', 'day')).toBeTruthy(); + expect(isCronFieldDisabled('day', 'weekDay')).toBeTruthy(); + expect(isCronFieldDisabled('day', 'startHour')).toBeFalsy(); + expect(isCronFieldDisabled('day', 'startMinute')).toBeFalsy(); + }); + + it('should disable month, day, weekDay, minute for period = hour', () => { + expect(isCronFieldDisabled('hour', 'month')).toBeTruthy(); + expect(isCronFieldDisabled('hour', 'day')).toBeTruthy(); + expect(isCronFieldDisabled('hour', 'weekDay')).toBeTruthy(); + expect(isCronFieldDisabled('hour', 'startHour')).toBeTruthy(); + expect(isCronFieldDisabled('hour', 'startMinute')).toBeFalsy(); + }); + }); + + describe('getOptionFromPeriodType', () => { + it('should get the right option for a period type', () => { + expect(getOptionFromPeriodType('year')).toEqual({ value: 'year', label: 'Year' }); + expect(getOptionFromPeriodType('month')).toEqual({ value: 'month', label: 'Month' }); + expect(getOptionFromPeriodType('week')).toEqual({ value: 'week', label: 'Week' }); + expect(getOptionFromPeriodType('day')).toEqual({ value: 'day', label: 'Day' }); + expect(getOptionFromPeriodType('hour')).toEqual({ value: 'hour', label: 'Hour' }); + }); + }); + + describe('getOptionFromDigit', () => { + it('should return an option with the correct number of digits', () => { + expect(getOptionFromDigit(0)).toEqual({ value: 0, label: '00' }); + expect(getOptionFromDigit(5)).toEqual({ value: 5, label: '05' }); + expect(getOptionFromDigit(8)).toEqual({ value: 8, label: '08' }); + expect(getOptionFromDigit(10)).toEqual({ value: 10, label: '10' }); + expect(getOptionFromDigit(54)).toEqual({ value: 54, label: '54' }); + }); + }); + + describe('getBackupModeOptions', () => { + it('should return backup mode options according to vendor', () => { + const mongoOptions = getBackupModeOptions(Databases.mongodb); + const mySqlOptions = getBackupModeOptions(Databases.mysql); + expect(mongoOptions).toHaveLength(2); + expect(mySqlOptions).toHaveLength(2); + expect(mongoOptions[0].value).toBe(BackupMode.PITR); + expect(mongoOptions[1].value).toBe(BackupMode.SNAPSHOT); + expect(mySqlOptions[0].value).toBe(BackupMode.INCREMENTAL); + expect(mySqlOptions[1].value).toBe(BackupMode.SNAPSHOT); + }); + }); + + describe('getDataModelFromVendor', () => { + it('should return data model according to vendor', () => { + expect(getDataModelFromVendor(Databases.mongodb)).toBe(DataModel.LOGICAL); + expect(getDataModelFromVendor(Databases.mysql)).toBe(DataModel.PHYSICAL); + }); + }); +}); diff --git a/public/app/percona/backup/components/AddBackupModal/AddBackupModal.utils.ts b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.utils.ts new file mode 100644 index 0000000000000..b9b67717be063 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/AddBackupModal.utils.ts @@ -0,0 +1,166 @@ +import { SelectableValue } from '@grafana/data'; +import { BackupMode, DataModel, RetryMode } from 'app/percona/backup/Backup.types'; +import { Databases } from 'app/percona/shared/core'; +import { getPeriodFromCronparts, parseCronString } from 'app/percona/shared/helpers/cron/cron'; +import { PeriodType } from 'app/percona/shared/helpers/cron/types'; +import { formatBackupMode } from '../../Backup.utils'; +import { Backup } from '../BackupInventory/BackupInventory.types'; +import { ScheduledBackup } from '../ScheduledBackups/ScheduledBackups.types'; +import { AddBackupFormProps } from './AddBackupModal.types'; + +export const PERIOD_OPTIONS: Array> = [ + { + value: 'year', + label: 'Year', + }, + { + value: 'month', + label: 'Month', + }, + { + value: 'week', + label: 'Week', + }, + { + value: 'day', + label: 'Day', + }, + { + value: 'hour', + label: 'Hour', + }, + { + value: 'minute', + label: 'Minute', + }, +]; + +const isScheduledBackup = (backup: Backup | ScheduledBackup): backup is ScheduledBackup => + (backup as ScheduledBackup).cronExpression !== undefined; + +export const toFormBackup = (backup: Backup | ScheduledBackup | null): AddBackupFormProps => { + if (!backup) { + return { + id: '', + service: null, + dataModel: DataModel.PHYSICAL, + retryMode: RetryMode.MANUAL, + retryTimes: 2, + retryInterval: 30, + backupName: '', + description: '', + location: null, + retention: 7, + period: { value: 'year', label: 'Year' }, + month: [], + day: [], + weekDay: [], + startHour: [{ value: 0, label: '00' }], + startMinute: [{ value: 0, label: '00' }], + logs: false, + active: true, + vendor: null, + mode: BackupMode.SNAPSHOT, + }; + } + + const { name, serviceName, serviceId, vendor, dataModel, locationName, locationId, id, mode } = backup; + + let month: Array> = []; + let day: Array> = []; + let weekDay: Array> = []; + let startHour: Array> = []; + let startMinute: Array> = []; + let period: SelectableValue = PERIOD_OPTIONS[0]; + let active = false; + let description = ''; + + if (isScheduledBackup(backup)) { + const { cronExpression, enabled, description: backupDescription, retention, retryInterval, retryTimes } = backup; + const cronParts = parseCronString(cronExpression); + const periodType = getPeriodFromCronparts(cronParts); + const [minutePart, hourPart, dayPart, monthPart, weekDayPary] = cronParts; + active = enabled; + description = backupDescription; + startMinute = minutePart.map((v) => getOptionFromDigit(v)); + startHour = hourPart.map((v) => getOptionFromDigit(v)); + day = dayPart.map((v) => getOptionFromDigit(v)); + month = monthPart.map((v) => getOptionFromDigit(v)); + weekDay = weekDayPary.map((v) => getOptionFromDigit(v)); + period = getOptionFromPeriodType(periodType); + + return { + id, + service: { label: serviceName, value: { id: serviceId, vendor } }, + dataModel, + backupName: name, + description, + location: { label: locationName, value: locationId }, + retryMode: retryTimes > 0 ? RetryMode.AUTO : RetryMode.MANUAL, + retryTimes: retryTimes || 2, + retryInterval: parseInt(retryInterval || '30', 10), + retention, + period, + month, + day, + weekDay, + startHour, + startMinute, + logs: false, + active, + vendor, + mode, + }; + } else { + return { + id, + mode, + vendor, + service: { label: serviceName, value: { id: serviceId, vendor } }, + dataModel, + backupName: name, + description, + location: { label: locationName, value: locationId }, + retryMode: RetryMode.MANUAL, + retryTimes: 2, + retryInterval: 30, + }; + } +}; + +type BackupFormTimeProps = keyof Pick; +export const isCronFieldDisabled = (period: PeriodType, field: BackupFormTimeProps) => { + const map: Record = { + year: [], + month: ['month'], + week: ['month', 'day'], + day: ['month', 'day', 'weekDay'], + hour: ['month', 'day', 'weekDay', 'startHour'], + minute: ['month', 'day', 'weekDay', 'startHour', 'startMinute'], + }; + + return map[period].includes(field); +}; + +export const getOptionFromPeriodType = (period: PeriodType): SelectableValue => + PERIOD_OPTIONS.find((p) => p.value === period)!; + +export const getOptionFromDigit = (value: number): SelectableValue => ({ + value, + label: value < 10 ? `0${value.toString()}` : value.toString(), +}); + +export const getBackupModeOptions = (db: Databases | null): Array> => { + const pitrDbs: Array = [Databases.mongodb]; + const isPitr = pitrDbs.includes(db); + const modes = [isPitr ? BackupMode.PITR : BackupMode.INCREMENTAL, BackupMode.SNAPSHOT]; + return modes.map((mode) => ({ + value: mode, + label: formatBackupMode(mode), + })); +}; + +export const getDataModelFromVendor = (db: Databases): DataModel => { + const logicalDbs = [Databases.mongodb]; + return logicalDbs.includes(db) ? DataModel.LOGICAL : DataModel.PHYSICAL; +}; diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.constants.ts b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.constants.ts new file mode 100644 index 0000000000000..05b07eadae07a --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.constants.ts @@ -0,0 +1,4 @@ +export const MINIMUM_RETRY = 1; +export const MAXIMUM_RETRY = 10; +export const MINIMUM_RETRY_TIME = 1; +export const MAXIMUM_RETRY_TIME = 8 * 60 * 60; diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.styles.ts b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.styles.ts new file mode 100644 index 0000000000000..3636958b4ca31 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.styles.ts @@ -0,0 +1,19 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ colors, typography, spacing, border }: GrafanaTheme) => ({ + retryFields: css` + display: flex; + `, + retrySelect: css` + flex: 1 1 50%; + + &:first-child { + padding-right: ${spacing.sm}; + } + + &:last-child { + padding-left: ${spacing.sm}; + } + `, +}); diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.test.tsx b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.test.tsx new file mode 100644 index 0000000000000..dcdb34d29b95f --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { RetryModeSelector } from './RetryModeSelector'; +import { FormWrapper } from '@percona/platform-core'; +import { RetryMode } from 'app/percona/backup/Backup.types'; +import { render, screen } from '@testing-library/react'; + +describe('RetryModeSelector', () => { + it('should render', () => { + render( + + + + ); + expect(screen.getAllByTestId('retryMode-radio-state')).toHaveLength(1); + expect(screen.getAllByTestId('retryTimes-field-container')).toHaveLength(1); + expect(screen.getAllByTestId('retryInterval-field-container')).toHaveLength(1); + expect(screen.getByTestId('retryTimes-number-input')).toHaveProperty('disabled', false); + }); + + it('should disable number inputs when retry mode is MANUAL', () => { + render( + + + + ); + expect(screen.getByTestId('retryTimes-number-input')).toBeDisabled(); + }); + + it('should disable all fields when disabled is passed, even if retry mode is AUTO', () => { + render( + + + + ); + expect(screen.getByTestId('retryTimes-number-input')).toBeDisabled(); + expect(screen.getAllByTestId('retryMode-radio-button')[0]).toBeDisabled(); + }); +}); diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.tsx b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.tsx new file mode 100644 index 0000000000000..4ab2789956d48 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useStyles } from '@grafana/ui'; +import { NumberInputField, RadioButtonGroupField } from '@percona/platform-core'; +import { RetryMode } from 'app/percona/backup/Backup.types'; +import { Messages } from '../AddBackupModal.messages'; +import { RETRY_MODE_OPTIONS } from '../AddBackupModal.constants'; +import { RetryModeSelectorProps } from './RetryModeSelector.types'; +import { getStyles } from './RetryModeSelector.styles'; +import { retryTimesValidator, retryIntervalValidator } from './RetryModeSelector.utils'; + +export const RetryModeSelector = ({ retryMode, disabled = false }: RetryModeSelectorProps) => { + const disabledNumberInputs = retryMode === RetryMode.MANUAL || disabled; + const styles = useStyles(getStyles); + + return ( +
    + +
    + + +
    +
    + ); +}; diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.types.ts b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.types.ts new file mode 100644 index 0000000000000..150a9eae6ef9c --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.types.ts @@ -0,0 +1,4 @@ +import { AddBackupFormProps } from '../AddBackupModal.types'; +export interface RetryModeSelectorProps extends Pick { + disabled?: boolean; +} diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.utils.ts b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.utils.ts new file mode 100644 index 0000000000000..9bbf7c4198ce0 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/RetryModeSelector.utils.ts @@ -0,0 +1,14 @@ +import { RetryMode } from 'app/percona/backup/Backup.types'; +import validators from 'app/percona/shared/helpers/validators'; +import { AddBackupFormProps } from '../AddBackupModal.types'; +import { MAXIMUM_RETRY, MAXIMUM_RETRY_TIME, MINIMUM_RETRY, MINIMUM_RETRY_TIME } from './RetryModeSelector.constants'; + +export const retryTimesValidator = (value: string, { retryMode }: AddBackupFormProps) => + retryMode === RetryMode.MANUAL + ? undefined + : validators.required(value) || validators.range(MINIMUM_RETRY, MAXIMUM_RETRY)(value); + +export const retryIntervalValidator = (value: RetryMode, { retryMode }: AddBackupFormProps) => + retryMode === RetryMode.MANUAL + ? undefined + : validators.required(value) || validators.range(MINIMUM_RETRY_TIME, MAXIMUM_RETRY_TIME)(value); diff --git a/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/index.tsx b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/index.tsx new file mode 100644 index 0000000000000..07b952270ea90 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/RetryModeSelector/index.tsx @@ -0,0 +1 @@ +export * from './RetryModeSelector'; diff --git a/public/app/percona/backup/components/AddBackupModal/__mocks__/AddBackupModal.service.ts b/public/app/percona/backup/components/AddBackupModal/__mocks__/AddBackupModal.service.ts new file mode 100644 index 0000000000000..8d97536cb7df9 --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/__mocks__/AddBackupModal.service.ts @@ -0,0 +1,30 @@ +import { SelectableValue } from '@grafana/data'; +import { Databases } from 'app/percona/shared/core'; +import * as service from '../AddBackupModal.service'; +import { SelectableService } from '../AddBackupModal.types'; + +export const serviceStubs: Array> = [ + { + label: 'service_1', + value: { id: 'Service 1', vendor: Databases.mongodb }, + }, + { + label: 'service_2', + value: { id: 'Service 2', vendor: Databases.mysql }, + }, +]; +export const locationsStubs: Array> = [ + { + label: 'location_1', + value: 'Location 1', + }, + { + label: 'location_2', + value: 'Location 2', + }, +]; + +export const AddBackupModalService = jest.genMockFromModule('../AddBackupModal.service') + .AddBackupModalService; +AddBackupModalService.loadServiceOptions = () => Promise.resolve(serviceStubs); +AddBackupModalService.loadLocationOptions = () => Promise.resolve(locationsStubs); diff --git a/public/app/percona/backup/components/AddBackupModal/index.ts b/public/app/percona/backup/components/AddBackupModal/index.ts new file mode 100644 index 0000000000000..8ebef6e3ffead --- /dev/null +++ b/public/app/percona/backup/components/AddBackupModal/index.ts @@ -0,0 +1 @@ +export * from './AddBackupModal'; diff --git a/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.messages.ts b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.messages.ts new file mode 100644 index 0000000000000..7c7a2693d9a15 --- /dev/null +++ b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + problemOcurred: 'Some problem ocurred', + readMore: 'Read more', +}; diff --git a/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.styles.ts b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.styles.ts new file mode 100644 index 0000000000000..91e37209c5013 --- /dev/null +++ b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.styles.ts @@ -0,0 +1,22 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing, palette, colors }: GrafanaTheme) => ({ + apiErrorCard: css` + margin-bottom: ${spacing.md}; + `, + apiErrorSection: css` + margin-top: ${spacing.sm}; + `, + errorLine: css` + &:not(:first-child) { + margin-top: ${spacing.xs}; + } + `, + errorText: css` + color: ${palette.redBase}; + `, + readMore: css` + color: ${colors.linkExternal}; + `, +}); diff --git a/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.tsx b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.tsx new file mode 100644 index 0000000000000..c8269be09b917 --- /dev/null +++ b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.tsx @@ -0,0 +1,28 @@ +import { Card, useStyles } from '@grafana/ui'; +import React, { FC } from 'react'; +import { BackupErrorSectionProps } from './BackupErrorSection.types'; +import { Messages } from './BackupErrorSection.messages'; +import { getStyles } from './BackupErrorSection.styles'; + +export const BackupErrorSection: FC = ({ backupErrors = [] }) => { + const styles = useStyles(getStyles); + + return ( + + +
    + {backupErrors.map((error) => ( +
    + {error.message} + {error.link && ( + + {Messages.readMore} + + )} +
    + ))} +
    +
    +
    + ); +}; diff --git a/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.types.ts b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.types.ts new file mode 100644 index 0000000000000..8966d41f3b200 --- /dev/null +++ b/public/app/percona/backup/components/BackupErrorSection/BackupErrorSection.types.ts @@ -0,0 +1,5 @@ +import { ApiVerboseError } from 'app/percona/shared/core'; + +export interface BackupErrorSectionProps { + backupErrors: ApiVerboseError[]; +} diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.constants.ts b/public/app/percona/backup/components/BackupInventory/BackupInventory.constants.ts new file mode 100644 index 0000000000000..d39b64dd0c55d --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.constants.ts @@ -0,0 +1,5 @@ +export const DATA_INTERVAL = 5000; +export const LIST_ARTIFACTS_CANCEL_TOKEN = 'listArtifacts'; +export const BACKUP_CANCEL_TOKEN = 'backup'; +export const RESTORE_CANCEL_TOKEN = 'restore'; +export const LOGS_LIMIT = 50; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.service.ts b/public/app/percona/backup/components/BackupInventory/BackupInventory.service.ts new file mode 100644 index 0000000000000..07658fed8691d --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.service.ts @@ -0,0 +1,109 @@ +import { DBServiceList, ServiceListPayload } from 'app/percona/inventory/Inventory.types'; +import { api } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { BackupLogResponse, BackupLogs } from '../../Backup.types'; +import { Backup, BackupResponse } from './BackupInventory.types'; + +const BASE_URL = '/v1/management/backup'; + +export const BackupInventoryService = { + async list(token?: CancelToken): Promise { + const { artifacts = [] } = await api.post(`${BASE_URL}/Artifacts/List`, {}, false, token); + return artifacts.map( + ({ + artifact_id, + name, + location_id, + location_name, + created_at, + service_id, + service_name, + data_model, + status, + vendor, + mode, + }): Backup => ({ + id: artifact_id, + name, + created: new Date(created_at).getTime(), + locationId: location_id, + locationName: location_name, + serviceId: service_id, + serviceName: service_name, + dataModel: data_model, + status, + vendor, + mode, + }) + ); + }, + async restore(serviceId: string, artifactId: string, token?: CancelToken) { + return api.post( + `${BASE_URL}/Backups/Restore`, + { + service_id: serviceId, + artifact_id: artifactId, + }, + false, + token + ); + }, + async backup( + serviceId: string, + locationId: string, + name: string, + description: string, + retryInterval: string, + retryTimes: number, + token?: CancelToken + ) { + return api.post( + `${BASE_URL}/Backups/Start`, + { + service_id: serviceId, + location_id: locationId, + name, + description, + retry_interval: retryInterval, + retries: retryTimes, + }, + false, + token + ); + }, + async delete(artifactId: string, removeFiles: boolean) { + return api.post(`${BASE_URL}/Artifacts/Delete`, { artifact_id: artifactId, remove_files: removeFiles }); + }, + async getLogs(artifactId: string, offset: number, limit: number, token?: CancelToken): Promise { + const { logs = [], end } = await api.post( + `${BASE_URL}/Backups/GetLogs`, + { + artifact_id: artifactId, + offset, + limit, + }, + false, + token + ); + + return { + logs: logs.map(({ chunk_id = 0, data, time }) => ({ id: chunk_id, data, time })), + end, + }; + }, + async listCompatibleServices(artifactId: string): Promise { + const { mysql = [], mongodb = [] } = await api.post( + `${BASE_URL}/Backups/ListArtifactCompatibleServices`, + { + artifact_id: artifactId, + } + ); + + const result: DBServiceList = { + mysql: mysql.map(({ service_id, service_name }) => ({ id: service_id, name: service_name })), + mongodb: mongodb.map(({ service_id, service_name }) => ({ id: service_id, name: service_name })), + }; + + return result; + }, +}; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.styles.ts b/public/app/percona/backup/components/BackupInventory/BackupInventory.styles.ts new file mode 100644 index 0000000000000..bb92505392b38 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.styles.ts @@ -0,0 +1,10 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + addWrapper: css` + display: flex; + justify-content: flex-end; + margin-bottom: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx new file mode 100644 index 0000000000000..5686d4fc2fc81 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { configureStore } from 'app/store/configureStore'; +import { StoreState } from 'app/types'; +import { Provider } from 'react-redux'; +import { BackupInventory } from './BackupInventory'; +import { render, screen } from '@testing-library/react'; + +jest.mock('./BackupInventory.service'); +jest.mock('../../hooks/recurringCall.hook'); + +describe('BackupInventory', () => { + it('should send correct data to Table', async () => { + render( + + + + ); + + await screen.findByText('Backup 1'); + expect(screen.getByText('Location 1')).toBeTruthy(); + }); +}); diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx new file mode 100644 index 0000000000000..f835109081a30 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.tsx @@ -0,0 +1,316 @@ +/* eslint-disable react/display-name */ +import React, { FC, useMemo, useState, useEffect, useCallback } from 'react'; +import { Column, Row } from 'react-table'; +import { Button, useStyles } from '@grafana/ui'; +import { logger } from '@percona/platform-core'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { TechnicalPreview } from 'app/percona/shared/components/Elements/TechnicalPreview/TechnicalPreview'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { apiErrorParser, isApiCancelError } from 'app/percona/shared/helpers/api'; +import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { Table } from 'app/percona/integrated-alerting/components/Table'; +import { ApiVerboseError, Databases, DATABASE_LABELS } from 'app/percona/shared/core'; +import { ExpandableCell } from 'app/percona/shared/components/Elements/ExpandableCell/ExpandableCell'; +import { BackupInventoryDetails } from './BackupInventoryDetails'; +import { AddBackupModal } from '../AddBackupModal'; +import { AddBackupFormProps } from '../AddBackupModal/AddBackupModal.types'; +import { Status } from '../Status'; +import { BackupInventoryActions } from './BackupInventoryActions'; +import { DetailedDate } from '../DetailedDate'; +import { Messages } from '../../Backup.messages'; +import { Backup } from './BackupInventory.types'; +import { BackupInventoryService } from './BackupInventory.service'; +import { RestoreBackupModal } from './RestoreBackupModal'; +import { getStyles } from './BackupInventory.styles'; +import { useRecurringCall } from '../../hooks/recurringCall.hook'; +import { + BACKUP_CANCEL_TOKEN, + LIST_ARTIFACTS_CANCEL_TOKEN, + RESTORE_CANCEL_TOKEN, + DATA_INTERVAL, +} from './BackupInventory.constants'; +import { DeleteModal } from 'app/percona/shared/components/Elements/DeleteModal'; +import { RetryMode } from '../../Backup.types'; +import { formatBackupMode } from '../../Backup.utils'; +import { BackupLogsModal } from './BackupLogsModal/BackupLogsModal'; +import { CancelToken } from 'axios'; + +export const BackupInventory: FC = () => { + const [pending, setPending] = useState(true); + const [deletePending, setDeletePending] = useState(false); + const [restoreModalVisible, setRestoreModalVisible] = useState(false); + const [selectedBackup, setSelectedBackup] = useState(null); + const [backupModalVisible, setBackupModalVisible] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [logsModalVisible, setLogsModalVisible] = useState(false); + const [data, setData] = useState([]); + const [backupErrors, setBackupErrors] = useState([]); + const [restoreErrors, setRestoreErrors] = useState([]); + const navModel = usePerconaNavModel('backup-inventory'); + const [triggerTimeout] = useRecurringCall(); + const [generateToken] = useCancelToken(); + const columns = useMemo( + (): Array> => [ + { + Header: Messages.backupInventory.table.columns.name, + accessor: 'name', + id: 'name', + width: '250px', + Cell: ({ row, value }) => , + }, + { + Header: Messages.backupInventory.table.columns.vendor, + accessor: ({ vendor }: Backup) => DATABASE_LABELS[vendor], + width: '150px', + }, + { + Header: Messages.backupInventory.table.columns.created, + accessor: 'created', + Cell: ({ value }) => , + }, + { + Header: Messages.backupInventory.table.columns.type, + accessor: 'mode', + Cell: ({ value }) => formatBackupMode(value), + }, + { + Header: Messages.backupInventory.table.columns.location, + accessor: 'locationName', + }, + { + Header: Messages.backupInventory.table.columns.status, + accessor: 'status', + Cell: ({ value, row }) => ( + onLogClick(row.original)} + /> + ), + }, + { + Header: Messages.backupInventory.table.columns.actions, + accessor: 'id', + Cell: ({ row }) => ( + + ), + width: '150px', + }, + ], + [] + ); + const styles = useStyles(getStyles); + + const onRestoreClick = (backup: Backup) => { + setSelectedBackup(backup); + setRestoreModalVisible(true); + }; + + const onDeleteClick = (backup: Backup) => { + setSelectedBackup(backup); + setDeleteModalVisible(true); + }; + + const onLogClick = (backup: Backup) => { + setSelectedBackup(backup); + setLogsModalVisible(true); + }; + + const handleClose = () => { + setSelectedBackup(null); + setRestoreModalVisible(false); + setBackupModalVisible(false); + setBackupErrors([]); + setRestoreErrors([]); + }; + + const handleLogsClose = () => { + setSelectedBackup(null); + setLogsModalVisible(false); + }; + + const handleRestore = async (serviceId: string, artifactId: string) => { + try { + await BackupInventoryService.restore(serviceId, artifactId, generateToken(RESTORE_CANCEL_TOKEN)); + setRestoreErrors([]); + setRestoreModalVisible(false); + } catch (e) { + setRestoreErrors(apiErrorParser(e)); + logger.error(e); + } + }; + + const getData = useCallback(async (showLoading = false) => { + showLoading && setPending(true); + + try { + const backups = await BackupInventoryService.list(generateToken(LIST_ARTIFACTS_CANCEL_TOKEN)); + setData(backups); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setPending(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleDelete = useCallback( + async (force = false) => { + try { + await BackupInventoryService.delete(selectedBackup!.id, force); + setDeleteModalVisible(false); + setSelectedBackup(null); + getData(true); + } catch (e) { + logger.error(e); + } finally { + setDeletePending(false); + } + }, + [getData, selectedBackup] + ); + + const getLogs = async (startingChunk: number, offset: number, token?: CancelToken) => { + return BackupInventoryService.getLogs(selectedBackup!.id, startingChunk, offset, token); + }; + + const renderSelectedSubRow = React.useCallback( + (row: Row) => ( + + ), + [] + ); + + const onBackupClick = (backup: Backup | null) => { + setSelectedBackup(backup); + setBackupModalVisible(true); + }; + + const handleBackup = async ({ + service, + location, + backupName, + description, + retryMode, + retryInterval, + retryTimes, + }: AddBackupFormProps) => { + const strRetryInterval = `${retryInterval}s`; + let resultRetryTimes = retryMode === RetryMode.MANUAL ? 0 : retryTimes; + try { + await BackupInventoryService.backup( + service!.value?.id || '', + location!.value || '', + backupName, + description, + strRetryInterval, + resultRetryTimes!, + generateToken(BACKUP_CANCEL_TOKEN) + ); + setBackupModalVisible(false); + setSelectedBackup(null); + setBackupErrors([]); + getData(true); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + + setBackupErrors(apiErrorParser(e)); + logger.error(e); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('backupEnabled'), []); + + useEffect(() => { + getData(true).then(() => triggerTimeout(getData, DATA_INTERVAL)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + +
    + +
    +
    + {restoreModalVisible && ( + + )} + {backupModalVisible && ( + + )} + {deleteModalVisible && ( + + )} + {logsModalVisible && ( + + )} + + + + ); +}; + +export default BackupInventory; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventory.types.ts b/public/app/percona/backup/components/BackupInventory/BackupInventory.types.ts new file mode 100644 index 0000000000000..166ab0c81453b --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventory.types.ts @@ -0,0 +1,33 @@ +import { Databases } from 'app/percona/shared/core'; +import { DataModel, BackupStatus, BackupMode } from '../../Backup.types'; +export interface Backup { + id: string; + name: string; + created: number; + locationId: string; + locationName: string; + serviceId: string; + serviceName: string; + dataModel: DataModel; + status: BackupStatus; + vendor: Databases; + mode: BackupMode; +} + +export interface RawBackup { + artifact_id: string; + name: string; + location_id: string; + location_name: string; + created_at: string; + service_id: string; + service_name: string; + data_model: DataModel; + status: BackupStatus; + vendor: Databases; + mode: BackupMode; +} + +export interface BackupResponse { + artifacts: RawBackup[]; +} diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.messages.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.messages.ts new file mode 100644 index 0000000000000..28017c4ac78e8 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + restoreBackup: 'Restore from backup', + addBackup: 'Create backup', + deleteBackup: 'Delete backup', +}; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.styles.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.styles.ts new file mode 100644 index 0000000000000..e05492bbac588 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.styles.ts @@ -0,0 +1,8 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + actionsWrapper: css` + display: flex; + justify-content: space-around; + `, +}); diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.tsx new file mode 100644 index 0000000000000..e51e48b7cf056 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { DBIcon } from '../../DBIcon'; +import { BackupStatus } from 'app/percona/backup/Backup.types'; +import { BackupInventoryActionsProps } from './BackupInventoryActions.types'; +import { getStyles } from './BackupInventoryActions.styles'; +import { Messages } from './BackupInventoryActions.messages'; + +export const BackupInventoryActions: FC = ({ backup, onRestore, onBackup, onDelete }) => { + const styles = useStyles(getStyles); + const handeClick = () => onRestore(backup); + const handleBackup = () => onBackup(backup); + const handleDelete = () => onDelete(backup); + + return ( +
    + + + +
    + ); +}; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.types.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.types.ts new file mode 100644 index 0000000000000..0eafb012d58d1 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/BackupInventoryActions.types.ts @@ -0,0 +1,8 @@ +import { Backup } from '../BackupInventory.types'; + +export interface BackupInventoryActionsProps { + backup: Backup; + onRestore: (backup: Backup) => void; + onBackup: (backup: Backup) => void; + onDelete: (backup: Backup) => void; +} diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/index.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/index.ts new file mode 100644 index 0000000000000..6b53a635bf86e --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryActions/index.ts @@ -0,0 +1 @@ +export * from './BackupInventoryActions'; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.messages.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.messages.ts new file mode 100644 index 0000000000000..1c33f54db1050 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + backupName: 'Backup name', + testResuts: 'Test results', + dataModel: 'Data model', +}; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.styles.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.styles.ts new file mode 100644 index 0000000000000..782e618969822 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.styles.ts @@ -0,0 +1,20 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + detailsWrapper: css` + display: flex; + + & > span { + flex: 0 1 50%; + word-break: break-all; + + &:first-child { + margin-right: ${spacing.sm}; + } + } + `, + detailLabel: css` + margin-right: ${spacing.md}; + `, +}); diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.test.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.test.tsx new file mode 100644 index 0000000000000..1fc29b2eccf45 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { BackupInventoryDetails } from './BackupInventoryDetails'; +import { DataModel, BackupStatus } from 'app/percona/backup/Backup.types'; +import { render, screen } from '@testing-library/react'; + +describe('BackupInventoryDetails', () => { + it('should have all fields', () => { + render( + + ); + expect(screen.getByTestId('backup-artifact-details-name')).toBeInTheDocument(); + expect(screen.getByTestId('backup-artifact-details-data-model')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.tsx b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.tsx new file mode 100644 index 0000000000000..65e9ece4a6756 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { BackupInventoryDetailsProps } from './BackupInventoryDetails.types'; +import { formatDataModel } from 'app/percona/backup/Backup.utils'; +import { Messages } from './BackupInventoryDetails.messages'; +import { getStyles } from './BackupInventoryDetails.styles'; + +export const BackupInventoryDetails: FC = ({ name, status, dataModel }) => { + const styles = useStyles(getStyles); + const dataModelMsg = formatDataModel(dataModel); + + return ( +
    + + {Messages.backupName} {name} + + + {Messages.dataModel} {dataModelMsg} + +
    + ); +}; diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.types.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.types.ts new file mode 100644 index 0000000000000..9195cff748dc9 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/BackupInventoryDetails.types.ts @@ -0,0 +1,7 @@ +import { BackupStatus, DataModel, RestoreStatus } from 'app/percona/backup/Backup.types'; + +export interface BackupInventoryDetailsProps { + name: string; + status: BackupStatus | RestoreStatus; + dataModel: DataModel; +} diff --git a/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/index.ts b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/index.ts new file mode 100644 index 0000000000000..162549c927d85 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupInventoryDetails/index.ts @@ -0,0 +1 @@ +export * from './BackupInventoryDetails'; diff --git a/public/app/percona/backup/components/BackupInventory/BackupLogsModal/BackupLogsModal.tsx b/public/app/percona/backup/components/BackupInventory/BackupLogsModal/BackupLogsModal.tsx new file mode 100644 index 0000000000000..f79715ae4e1a3 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupLogsModal/BackupLogsModal.tsx @@ -0,0 +1,12 @@ +import React, { FC } from 'react'; +import { Modal } from '@percona/platform-core'; +import { BackupLogsModalProps } from './BackupLogsModal.types'; +import { ChunkedLogsViewer } from '../../ChunkedLogsViewer/ChunkedLogsViewer'; + +export const BackupLogsModal: FC = ({ title, isVisible, onClose, getLogChunks }) => { + return ( + + + + ); +}; diff --git a/public/app/percona/backup/components/BackupInventory/BackupLogsModal/BackupLogsModal.types.ts b/public/app/percona/backup/components/BackupInventory/BackupLogsModal/BackupLogsModal.types.ts new file mode 100644 index 0000000000000..12d19b3333547 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/BackupLogsModal/BackupLogsModal.types.ts @@ -0,0 +1,9 @@ +import { BackupLogs } from 'app/percona/backup/Backup.types'; +import { CancelToken } from 'axios'; + +export interface BackupLogsModalProps { + isVisible: boolean; + title: string; + onClose: () => void; + getLogChunks: (offset: number, limit: number, token?: CancelToken) => Promise; +} diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.messages.ts b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.messages.ts new file mode 100644 index 0000000000000..98e05159a09f3 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.messages.ts @@ -0,0 +1,10 @@ +export const Messages = { + title: 'Restore from backup', + serviceSelection: 'Service selection', + vendor: 'Vendor', + serviceName: 'Service name', + dataModel: 'Data model', + restore: 'Restore', + close: 'Close', + noService: 'This service no longer exists. Please choose a compatible one.', +}; diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.service.ts b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.service.ts new file mode 100644 index 0000000000000..fec1c5a358f1a --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.service.ts @@ -0,0 +1,17 @@ +import { SelectableValue } from '@grafana/data'; +import { Databases } from 'app/percona/shared/core'; +import { BackupInventoryService } from '../BackupInventory.service'; + +export const RestoreBackupModalService = { + async loadLocationOptions(artifactId: string) { + const services = await BackupInventoryService.listCompatibleServices(artifactId); + const result: Array> = []; + + Object.keys(services).forEach((db) => { + const serviceArr = services[db as Databases] || []; + result.push(...serviceArr.map((service) => ({ label: service.name, value: service.id }))); + }); + + return result; + }, +}; diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.styles.ts b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.styles.ts new file mode 100644 index 0000000000000..23f1856f070cb --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.styles.ts @@ -0,0 +1,42 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ palette, typography, spacing }: GrafanaTheme) => ({ + formHalvesContainer: css` + display: flex; + margin-bottom: ${spacing.formInputMargin}; + + & > div { + flex: 0 1 50%; + + &:first-child { + padding-right: ${spacing.md}; + } + + &:last-child { + padding-left: ${spacing.md}; + } + } + `, + radioGroup: css` + & > div:nth-last-of-type(2) { + flex-wrap: nowrap; + } + + & input[type='radio'] + label { + height: auto; + white-space: nowrap; + padding: 7px 8px; + line-height: ${typography.lineHeight.md}; + } + `, + errorLine: css` + color: ${palette.redBase}; + font-size: ${typography.size.sm}; + height: ${typography.size.sm}; + line-height: ${typography.lineHeight.sm}; + margin-top: ${spacing.sm}; + padding: ${spacing.formLabelPadding}; + text-align: center; + `, +}); diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.test.tsx b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.test.tsx new file mode 100644 index 0000000000000..2f074f895b0f7 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Backup } from '../BackupInventory.types'; +import { RestoreBackupModal } from './RestoreBackupModal'; +import { BackupMode, BackupStatus, DataModel } from 'app/percona/backup/Backup.types'; +import { Databases } from 'app/percona/shared/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('RestoreBackupModal', () => { + const backup: Backup = { + id: 'backup1', + name: 'Backup 1', + created: 1621956564096, + locationId: 'location_1', + locationName: 'Location One', + serviceId: 'service_1', + serviceName: 'Service One', + dataModel: DataModel.PHYSICAL, + status: BackupStatus.BACKUP_STATUS_SUCCESS, + vendor: Databases.mongodb, + mode: BackupMode.SNAPSHOT, + }; + + it('should render', () => { + render(); + expect(screen.getByTestId('restore-button')).toBeInTheDocument(); + expect(screen.getByTestId('restore-cancel-button')).toBeInTheDocument(); + expect(screen.getByTestId('backup-modal-error').textContent).toHaveLength(0); + }); + + it('should block restore button and show error when noService is passed and same service is selected', () => { + render(); + + expect(screen.getByTestId('backup-modal-error').textContent).not.toHaveLength(0); + expect(screen.getAllByTestId('restore-button')[0]).toBeDisabled(); + }); + + it.skip('should not block restore button or show error when noService is passed and compatible service is selected', () => { + render(); + + const rButton = screen.getAllByTestId('serviceType-radio-button')[0]; + fireEvent.change(rButton); + + expect(screen.getByTestId('backup-modal-error').textContent).toHaveLength(0); + expect(screen.getAllByTestId('restore-button')[0]).not.toBeDisabled(); + }); +}); diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.tsx b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.tsx new file mode 100644 index 0000000000000..cbbb5f0155145 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.tsx @@ -0,0 +1,105 @@ +import React, { FC, useMemo } from 'react'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { Field, withTypes } from 'react-final-form'; +import { Modal, LoaderButton, RadioButtonGroupField, TextInputField, validators } from '@percona/platform-core'; +import { AsyncSelectField } from 'app/percona/shared/components/Form/AsyncSelectField'; +import { RestoreBackupModalProps, RestoreBackupFormProps, ServiceTypeSelect } from './RestoreBackupModal.types'; +import { Messages } from './RestoreBackupModal.messages'; +import { getStyles } from './RestoreBackupModal.styles'; +import { toFormProps } from './RestoreBackupModal.utils'; +import { RestoreBackupModalService } from './RestoreBackupModal.service'; +import { Databases, DATABASE_LABELS } from 'app/percona/shared/core'; +import { BackupErrorSection } from '../../BackupErrorSection/BackupErrorSection'; + +const { Form } = withTypes(); + +const serviceTypeOptions: Array> = [ + { + value: ServiceTypeSelect.SAME, + label: 'Same service', + }, + { + value: ServiceTypeSelect.COMPATIBLE, + label: 'Compatible services', + }, +]; + +export const RestoreBackupModal: FC = ({ + backup, + isVisible, + noService = false, + restoreErrors = [], + onClose, + onRestore, +}) => { + const styles = useStyles(getStyles); + const initialValues = useMemo(() => (backup ? toFormProps(backup) : undefined), [backup]); + const handleSubmit = ({ serviceType, service }: RestoreBackupFormProps) => { + if (backup) { + const serviceId = serviceType === ServiceTypeSelect.SAME ? backup.serviceId : service.value; + onRestore(serviceId || '', backup.id); + } + }; + + return ( + +
    ( + +
    +
    + + +
    +
    + + {({ input }) => ( +
    + RestoreBackupModalService.loadLocationOptions(backup!.id)} + defaultOptions + {...input} + data-testid="service-select-input" + /> +
    + )} +
    + +
    +
    + {!!restoreErrors.length && } + + + {Messages.restore} + + + +
    + {values.serviceType === ServiceTypeSelect.SAME && noService && Messages.noService} +
    + + )} + /> +
    + ); +}; diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.types.ts b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.types.ts new file mode 100644 index 0000000000000..0fc0d22f44d95 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.types.ts @@ -0,0 +1,24 @@ +import { SelectableValue } from '@grafana/data'; +import { ApiVerboseError } from 'app/percona/shared/core'; +import { Backup } from '../BackupInventory.types'; + +export interface RestoreBackupModalProps { + isVisible: boolean; + backup: Backup | null; + noService?: boolean; + restoreErrors?: ApiVerboseError[]; + onClose: () => void; + onRestore: (serviceId: string, artifactId: string) => void; +} + +export interface RestoreBackupFormProps { + serviceType: ServiceTypeSelect; + vendor: string; + service: SelectableValue; + dataModel: string; +} + +export enum ServiceTypeSelect { + SAME = 'SAME', + COMPATIBLE = 'COMPATIBLE', +} diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.utils.ts b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.utils.ts new file mode 100644 index 0000000000000..67df532128108 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/RestoreBackupModal.utils.ts @@ -0,0 +1,13 @@ +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { formatDataModel } from 'app/percona/backup/Backup.utils'; +import { Backup } from '../BackupInventory.types'; +import { RestoreBackupFormProps, ServiceTypeSelect } from './RestoreBackupModal.types'; + +type ToFormProps = (props: Backup) => RestoreBackupFormProps; + +export const toFormProps: ToFormProps = ({ vendor, serviceId, serviceName, dataModel }) => ({ + serviceType: ServiceTypeSelect.SAME, + vendor: DATABASE_LABELS[vendor], + service: { label: serviceName, value: serviceId }, + dataModel: formatDataModel(dataModel), +}); diff --git a/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/index.ts b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/index.ts new file mode 100644 index 0000000000000..4fd379e97bd8f --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/RestoreBackupModal/index.ts @@ -0,0 +1 @@ +export * from './RestoreBackupModal'; diff --git a/public/app/percona/backup/components/BackupInventory/__mocks__/BackupInventory.service.ts b/public/app/percona/backup/components/BackupInventory/__mocks__/BackupInventory.service.ts new file mode 100644 index 0000000000000..9146a51a659a9 --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/__mocks__/BackupInventory.service.ts @@ -0,0 +1,37 @@ +import { Backup } from '../BackupInventory.types'; +import { DataModel, BackupStatus, BackupMode } from 'app/percona/backup/Backup.types'; +import { Databases } from 'app/percona/shared/core'; +import * as service from '../BackupInventory.service'; + +export const stubs: Backup[] = [ + { + id: 'backup_1', + name: 'Backup 1', + locationId: 'location_1', + locationName: 'Location 1', + created: 1615912580244, + serviceId: 'service_1', + serviceName: 'Service 1', + dataModel: DataModel.LOGICAL, + status: BackupStatus.BACKUP_STATUS_SUCCESS, + vendor: Databases.mysql, + mode: BackupMode.SNAPSHOT, + }, + { + id: 'backup_2', + name: 'Backup 2', + locationId: 'location_2', + locationName: 'Location 2', + created: 1615912580244, + serviceId: 'service_2', + serviceName: 'Service 2', + dataModel: DataModel.PHYSICAL, + status: BackupStatus.BACKUP_STATUS_IN_PROGRESS, + vendor: Databases.mysql, + mode: BackupMode.SNAPSHOT, + }, +]; + +export const BackupInventoryService = jest.genMockFromModule('../BackupInventory.service') + .BackupInventoryService; +BackupInventoryService.list = () => Promise.resolve(stubs); diff --git a/public/app/percona/backup/components/BackupInventory/index.ts b/public/app/percona/backup/components/BackupInventory/index.ts new file mode 100644 index 0000000000000..dd85f3cedd0cb --- /dev/null +++ b/public/app/percona/backup/components/BackupInventory/index.ts @@ -0,0 +1 @@ +export * from './BackupInventory'; diff --git a/public/app/percona/backup/components/BucketBlock/BucketBlock.messages.ts b/public/app/percona/backup/components/BucketBlock/BucketBlock.messages.ts new file mode 100644 index 0000000000000..c41ed9078dbdc --- /dev/null +++ b/public/app/percona/backup/components/BucketBlock/BucketBlock.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + bucketName: 'Bucket name', +}; diff --git a/public/app/percona/backup/components/BucketBlock/BucketBlock.styles.ts b/public/app/percona/backup/components/BucketBlock/BucketBlock.styles.ts new file mode 100644 index 0000000000000..cfba19a6fbaea --- /dev/null +++ b/public/app/percona/backup/components/BucketBlock/BucketBlock.styles.ts @@ -0,0 +1,14 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + wrapper: css` + overflow: hidden; + text-overflow: ellipsis; + `, + nameSpan: css` + margin-right: ${spacing.md}; + font-weight: ${typography.weight.semibold}; + white-space: nowrap; + `, +}); diff --git a/public/app/percona/backup/components/BucketBlock/BucketBlock.test.tsx b/public/app/percona/backup/components/BucketBlock/BucketBlock.test.tsx new file mode 100644 index 0000000000000..e753b9efd19f1 --- /dev/null +++ b/public/app/percona/backup/components/BucketBlock/BucketBlock.test.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { BucketBlock } from './BucketBlock'; +import { render, screen } from '@testing-library/react'; + +describe('BucketBlock', () => { + it('should render', () => { + render(); + expect(screen.getByTestId('storage-location-bucket')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/backup/components/BucketBlock/BucketBlock.tsx b/public/app/percona/backup/components/BucketBlock/BucketBlock.tsx new file mode 100644 index 0000000000000..17200f27864e6 --- /dev/null +++ b/public/app/percona/backup/components/BucketBlock/BucketBlock.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { Messages } from './BucketBlock.messages'; +import { BucketBlockProps } from './BucketBlock.types'; +import { getStyles } from './BucketBlock.styles'; + +export const BucketBlock: FC = ({ bucketName }) => { + const styles = useStyles(getStyles); + + return ( +
    + {Messages.bucketName} + {bucketName} +
    + ); +}; diff --git a/public/app/percona/backup/components/BucketBlock/BucketBlock.types.ts b/public/app/percona/backup/components/BucketBlock/BucketBlock.types.ts new file mode 100644 index 0000000000000..ba27869b85e60 --- /dev/null +++ b/public/app/percona/backup/components/BucketBlock/BucketBlock.types.ts @@ -0,0 +1,3 @@ +export interface BucketBlockProps { + bucketName: string; +} diff --git a/public/app/percona/backup/components/BucketBlock/index.ts b/public/app/percona/backup/components/BucketBlock/index.ts new file mode 100644 index 0000000000000..fa210e80fc8f6 --- /dev/null +++ b/public/app/percona/backup/components/BucketBlock/index.ts @@ -0,0 +1 @@ +export * from './BucketBlock'; diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.constants.ts b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.constants.ts new file mode 100644 index 0000000000000..15077665fb344 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.constants.ts @@ -0,0 +1,4 @@ +export const LIMIT = 200; +export const BUFFER = 20; +export const STREAM_INTERVAL = 3000; +export const LOGS_CANCEL_TOKEN = 'LOGS_CANCEL_TOKEN'; diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.messages.ts b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.messages.ts new file mode 100644 index 0000000000000..274878e015de7 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + noLogs: 'No logs', + loading: 'Loading...', + copyToClipboard: 'Copy to clipboard', +}; diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.styles.ts b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.styles.ts new file mode 100644 index 0000000000000..10867edff5811 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.styles.ts @@ -0,0 +1,11 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + loadingHolder: css` + text-align: center; + `, + copyBtnHolder: css` + display: inline-block; + `, +}); diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.test.tsx b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.test.tsx new file mode 100644 index 0000000000000..1fb61a3ace626 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { BackupLogs } from '../../Backup.types'; +import { ChunkedLogsViewer } from './ChunkedLogsViewer'; +import { Messages } from './ChunkedLogsViewer.messages'; + +describe('ChunkedLogsViewer', () => { + const getMockedLogsGetter = (logs: BackupLogs, timeout = 10): jest.Mock => { + return jest.fn().mockReturnValue(new Promise((resolve) => setTimeout(() => resolve(logs), timeout))); + }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should show processing state in the beginning', () => { + const getLogs = getMockedLogsGetter({ logs: [], end: false }); + render(); + expect(screen.getByText(Messages.loading)).toBeInTheDocument(); + }); + + it('should show "no logs" message after loading is done', async () => { + const getLogs = getMockedLogsGetter({ logs: [], end: true }); + render(); + await waitFor(() => { + expect(screen.getByText(Messages.noLogs)).toBeInTheDocument(); + }); + }); + + it('should show logs', async () => { + const getLogs = getMockedLogsGetter({ + logs: [ + { id: 0, data: 'Log 1', time: '' }, + { id: 1, data: 'Log 2', time: '' }, + ], + end: true, + }); + render(); + await waitFor(() => { + expect(screen.getByText((content) => content.includes('Log 1') && content.includes('Log 2'))).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx new file mode 100644 index 0000000000000..8bdebba62aa55 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.tsx @@ -0,0 +1,65 @@ +import { useStyles, ClipboardButton } from '@grafana/ui'; +import { logger } from '@percona/platform-core'; +import React, { FC, useState, useEffect, useCallback } from 'react'; +import { BackupLogChunk } from '../../Backup.types'; +import { useRecurringCall } from '../../hooks/recurringCall.hook'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { LIMIT, STREAM_INTERVAL, LOGS_CANCEL_TOKEN } from './ChunkedLogsViewer.constants'; +import { getStyles } from './ChunkedLogsViewer.styles'; +import { ChunkedLogsViewerProps } from './ChunkedLogsViewer.types'; +import { Messages } from './ChunkedLogsViewer.messages'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; + +export const ChunkedLogsViewer: FC = ({ getLogChunks }) => { + const [lastLog, setLastLog] = useState(false); + const [logs, setLogs] = useState([]); + const [triggerTimeout, , stopTimeout] = useRecurringCall(); + const [generateToken] = useCancelToken(); + const styles = useStyles(getStyles); + + const formatLogs = useCallback( + () => logs.map((log) => log.data).reduce((acc, message) => `${acc}${acc.length ? '\n' : ''}${message}`, ''), + [logs] + ); + + useEffect(() => { + const refreshCurrentLogs = async () => { + try { + const { logs: newLogs = [], end } = await getLogChunks( + logs[0]?.id || 0, + LIMIT, + generateToken(LOGS_CANCEL_TOKEN) + ); + setLogs(newLogs); + setLastLog(!!end); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + }; + + triggerTimeout(refreshCurrentLogs, STREAM_INTERVAL, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [triggerTimeout, getLogChunks, logs]); + + useEffect(() => { + if (lastLog) { + stopTimeout(); + } + }, [lastLog, stopTimeout]); + + return ( + <> + + {Messages.copyToClipboard} + +
    +        {formatLogs()}
    +        {!lastLog && 
    {Messages.loading}
    } + {lastLog && !logs.length && Messages.noLogs} +
    + + ); +}; diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.types.ts b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.types.ts new file mode 100644 index 0000000000000..02e58d79aa188 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.types.ts @@ -0,0 +1,7 @@ +import { CancelToken } from 'axios'; +import { BackupLogs } from '../../Backup.types'; + +export interface ChunkedLogsViewerProps { + onMore?: () => void; + getLogChunks: (offset: number, limit: number, token?: CancelToken) => Promise; +} diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.utils.test.ts b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.utils.test.ts new file mode 100644 index 0000000000000..64f2fb313152e --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.utils.test.ts @@ -0,0 +1,276 @@ +import { concatenateNewerLogs, concatenateOlderLogs } from './ChunkedLogsViewer.utils'; + +describe('ChunkedLogsViewer::utils', () => { + it('should correctly concatenate newer logs', () => { + expect(concatenateNewerLogs([{ id: 0, data: '', time: '' }], [], 3, 0)).toEqual([{ id: 0, data: '', time: '' }]); + + expect( + concatenateNewerLogs( + [{ id: 2, data: '', time: '' }], + [ + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + ], + 2, + 1 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + ]); + + expect( + concatenateNewerLogs( + [{ id: 2, data: '', time: '' }], + [ + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + ], + 4, + 2 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + ]); + + expect( + concatenateNewerLogs( + [{ id: 2, data: '', time: '' }], + [ + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + { id: 8, data: '', time: '' }, + { id: 9, data: '', time: '' }, + ], + 1, + 5 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ]); + + expect( + concatenateNewerLogs( + [ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + ], + [ + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + { id: 8, data: '', time: '' }, + { id: 9, data: '', time: '' }, + ], + 4, + 2 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ]); + + expect( + concatenateNewerLogs( + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + ], + [ + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ], + 4, + 2 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ]); + + expect( + concatenateNewerLogs( + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + ], + [ + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ], + 2, + 2 + ) + ).toEqual([ + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ]); + }); + it('should correctly concatenate older logs', () => { + expect(concatenateOlderLogs([{ id: 0, data: '', time: '' }], [], 3, 1)).toEqual([{ id: 0, data: '', time: '' }]); + + expect( + concatenateOlderLogs( + [{ id: 3, data: '', time: '' }], + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + ], + 2, + 1 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + ]); + + expect( + concatenateOlderLogs( + [{ id: 3, data: '', time: '' }], + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + ], + 4, + 2 + ) + ).toEqual([ + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + ]); + + expect( + concatenateOlderLogs( + [{ id: 7, data: '', time: '' }], + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + ], + 1, + 5 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ]); + + expect( + concatenateOlderLogs( + [ + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ], + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + ], + 4, + 2 + ) + ).toEqual([ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ]); + + expect( + concatenateOlderLogs( + [ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ], + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + ], + 4, + 2 + ) + ).toEqual([ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + ]); + + expect( + concatenateOlderLogs( + [ + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + { id: 4, data: '', time: '' }, + { id: 5, data: '', time: '' }, + { id: 6, data: '', time: '' }, + { id: 7, data: '', time: '' }, + ], + [ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + ], + 2, + 2 + ) + ).toEqual([ + { id: 0, data: '', time: '' }, + { id: 1, data: '', time: '' }, + { id: 2, data: '', time: '' }, + { id: 3, data: '', time: '' }, + ]); + }); +}); diff --git a/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.utils.ts b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.utils.ts new file mode 100644 index 0000000000000..8365e0dcbe496 --- /dev/null +++ b/public/app/percona/backup/components/ChunkedLogsViewer/ChunkedLogsViewer.utils.ts @@ -0,0 +1,48 @@ +import { BackupLogChunk } from '../../Backup.types'; + +export const concatenateOlderLogs = ( + currentLogs: BackupLogChunk[], + newLogs: BackupLogChunk[], + limit: number, + buffer: number +): BackupLogChunk[] => { + if (newLogs.length) { + if (newLogs.length > buffer) { + newLogs = newLogs.slice(-buffer); + } + const totalNewLength = currentLogs.length + newLogs.length; + + if (totalNewLength > limit + buffer) { + const subLogs = currentLogs.slice(0, limit); + return [...newLogs, ...subLogs]; + } else { + return [...newLogs, ...currentLogs]; + } + } + + return currentLogs; +}; + +export const concatenateNewerLogs = ( + currentLogs: BackupLogChunk[], + newLogs: BackupLogChunk[], + limit: number, + buffer: number +): BackupLogChunk[] => { + if (newLogs.length) { + if (newLogs.length > buffer) { + newLogs = newLogs.slice(0, buffer); + } + + const totalNewLength = newLogs.length + currentLogs.length; + + if (totalNewLength > limit + buffer) { + const subLogs = currentLogs.slice(-limit); + return [...subLogs, ...newLogs]; + } else { + return [...currentLogs, ...newLogs]; + } + } + + return currentLogs; +}; diff --git a/public/app/percona/backup/components/DBIcon/DBIcon.styles.ts b/public/app/percona/backup/components/DBIcon/DBIcon.styles.ts new file mode 100644 index 0000000000000..ff6b6b6c86614 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/DBIcon.styles.ts @@ -0,0 +1,13 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ colors }: GrafanaTheme) => ({ + disabled: css` + color: ${colors.textFaint}; + + svg { + cursor: not-allowed; + pointer-events: none; + } + `, +}); diff --git a/public/app/percona/backup/components/DBIcon/DBIcon.test.tsx b/public/app/percona/backup/components/DBIcon/DBIcon.test.tsx new file mode 100644 index 0000000000000..777d5201a75ca --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/DBIcon.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { DBIcon } from './DBIcon'; +import { DBIconType } from './DBIcon.types'; +import { Tooltip } from '@grafana/ui'; +import { render, screen } from '@testing-library/react'; +import { svg } from '../../../../../test/mocks/svg'; + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + Tooltip: jest.fn(() =>
    ), +})); + +describe('DBIcon', () => { + it('should not display unknown icons', async () => { + render(); + expect(screen.queryAllByRole(svg)).toHaveLength(0); + }); + + it('should display known icons', () => { + const { container } = render(); + const svg = container.querySelectorAll('svg'); + expect(svg).toHaveLength(1); + }); + + it('should have 22 x 22 icons by default', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', '22'); + expect(svg).toHaveAttribute('height', '22'); + }); + + it('should change icon size', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', '30'); + expect(svg).toHaveAttribute('height', '30'); + }); + + it('should now show tooltip if no text is passed', () => { + render(); + expect(Tooltip).toHaveBeenCalledTimes(0); + }); + + it('should show tooltip if text is passed', async () => { + render(); + expect(Tooltip).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/percona/backup/components/DBIcon/DBIcon.tsx b/public/app/percona/backup/components/DBIcon/DBIcon.tsx new file mode 100644 index 0000000000000..4cb9fe34d6671 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/DBIcon.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { Tooltip, useStyles } from '@grafana/ui'; +import { cx } from '@emotion/css'; +import { DBIconProps, DBIconMap } from './DBIcon.types'; +import { Edit, Delete, See, Backup, Cancel, Restore } from './assets'; +import { getStyles } from './DBIcon.styles'; + +const Icons: DBIconMap = { + edit: Edit, + delete: Delete, + see: See, + restore: Restore, + backup: Backup, + cancel: Cancel, +}; + +export const DBIcon: FC = ({ type, size, tooltipText, disabled, ...rest }) => { + const styles = useStyles(getStyles); + if (!Icons[type]) { + return null; + } + const Icon = Icons[type]; + const IconEl = ( + + + + ); + + return tooltipText ? ( + + {IconEl} + + ) : ( + <>{IconEl} + ); +}; diff --git a/public/app/percona/backup/components/DBIcon/DBIcon.types.ts b/public/app/percona/backup/components/DBIcon/DBIcon.types.ts new file mode 100644 index 0000000000000..e0d90a91af7d6 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/DBIcon.types.ts @@ -0,0 +1,16 @@ +export type DBIconType = 'edit' | 'see' | 'delete' | 'backup' | 'cancel' | 'restore'; + +export interface DBIconProps extends React.HTMLAttributes { + type: DBIconType; + size?: number; + tooltipText?: string; + disabled?: boolean; +} + +export interface IconProps { + size?: number; +} + +export type DBIconMap = { + [key in DBIconType]: React.FC; +}; diff --git a/public/app/percona/backup/components/DBIcon/assets/Backup.tsx b/public/app/percona/backup/components/DBIcon/assets/Backup.tsx new file mode 100644 index 0000000000000..b2715b2e125ec --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/Backup.tsx @@ -0,0 +1,43 @@ +import React, { FC } from 'react'; +import { IconProps } from '../DBIcon.types'; + +export const Backup: FC = ({ size = 22, ...rest }) => ( + + + + + + + + + + +); diff --git a/public/app/percona/backup/components/DBIcon/assets/Cancel.tsx b/public/app/percona/backup/components/DBIcon/assets/Cancel.tsx new file mode 100644 index 0000000000000..6b72b05248a33 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/Cancel.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { IconProps } from '../DBIcon.types'; + +export const Cancel: FC = ({ size = 22, ...rest }) => ( + + + + + + + + + + +); diff --git a/public/app/percona/backup/components/DBIcon/assets/Delete.tsx b/public/app/percona/backup/components/DBIcon/assets/Delete.tsx new file mode 100644 index 0000000000000..f683d4fd91fbe --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/Delete.tsx @@ -0,0 +1,43 @@ +import React, { FC } from 'react'; +import { IconProps } from '../DBIcon.types'; + +export const Delete: FC = ({ size = 22, ...rest }) => ( + + + + + + + + + + +); diff --git a/public/app/percona/backup/components/DBIcon/assets/Edit.tsx b/public/app/percona/backup/components/DBIcon/assets/Edit.tsx new file mode 100644 index 0000000000000..4524d60094b1b --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/Edit.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react'; +import { IconProps } from '../DBIcon.types'; + +export const Edit: FC = ({ size = 22, ...rest }) => ( + + + + + + + + + + +); diff --git a/public/app/percona/backup/components/DBIcon/assets/Restore.tsx b/public/app/percona/backup/components/DBIcon/assets/Restore.tsx new file mode 100644 index 0000000000000..f4ff16d1f2e51 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/Restore.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { IconProps } from '../DBIcon.types'; + +export const Restore: FC = ({ size = 22, ...rest }) => ( + + + + + + + + + +); diff --git a/public/app/percona/backup/components/DBIcon/assets/See.tsx b/public/app/percona/backup/components/DBIcon/assets/See.tsx new file mode 100644 index 0000000000000..5e6ab223aa872 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/See.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; +import { IconProps } from '../DBIcon.types'; + +export const See: FC = ({ size = 22, ...rest }) => ( + + + + + + + + + + + +); diff --git a/public/app/percona/backup/components/DBIcon/assets/index.ts b/public/app/percona/backup/components/DBIcon/assets/index.ts new file mode 100644 index 0000000000000..629a73fbfdd38 --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/assets/index.ts @@ -0,0 +1,6 @@ +export * from './Edit'; +export * from './See'; +export * from './Delete'; +export * from './Restore'; +export * from './Backup'; +export * from './Cancel'; diff --git a/public/app/percona/backup/components/DBIcon/index.ts b/public/app/percona/backup/components/DBIcon/index.ts new file mode 100644 index 0000000000000..782132219861c --- /dev/null +++ b/public/app/percona/backup/components/DBIcon/index.ts @@ -0,0 +1 @@ +export * from './DBIcon'; diff --git a/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.messages.ts b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.messages.ts new file mode 100644 index 0000000000000..9b33be98bbaa6 --- /dev/null +++ b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + description: 'Description', +}; diff --git a/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.styles.ts b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.styles.ts new file mode 100644 index 0000000000000..c02b6a04e1b79 --- /dev/null +++ b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.styles.ts @@ -0,0 +1,19 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + descriptionWrapper: css` + display: flex; + align-items: center; + + span { + margin-right: ${spacing.md}; + font-weight: ${typography.weight.semibold}; + } + + pre { + margin-bottom: 0; + flex-grow: 1; + } + `, +}); diff --git a/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.test.tsx b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.test.tsx new file mode 100644 index 0000000000000..0f2d341dcdd29 --- /dev/null +++ b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.test.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { DescriptionBlock } from './DescriptionBlock'; +import { render } from '@testing-library/react'; + +describe('DescriptionBlock', () => { + it('should render description', () => { + const { container } = render(); + expect(container.querySelector('pre')).toHaveTextContent('sample_description'); + }); +}); diff --git a/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.tsx b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.tsx new file mode 100644 index 0000000000000..1c38ba1314e1b --- /dev/null +++ b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { DescriptionBlockProps } from './DescriptionBlock.types'; +import { Messages } from './DescriptionBlock.messages'; +import { getStyles } from './DescriptionBlock.styles'; + +export const DescriptionBlock: FC = ({ description, dataTestId }) => { + const styles = useStyles(getStyles); + + return ( +
    + {Messages.description} +
    {description}
    +
    + ); +}; diff --git a/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.types.ts b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.types.ts new file mode 100644 index 0000000000000..d57dbab1ee77c --- /dev/null +++ b/public/app/percona/backup/components/DescriptionBlock/DescriptionBlock.types.ts @@ -0,0 +1,4 @@ +export interface DescriptionBlockProps { + description: string; + dataTestId?: string; +} diff --git a/public/app/percona/backup/components/DescriptionBlock/index.ts b/public/app/percona/backup/components/DescriptionBlock/index.ts new file mode 100644 index 0000000000000..cae4d62b1d0d0 --- /dev/null +++ b/public/app/percona/backup/components/DescriptionBlock/index.ts @@ -0,0 +1 @@ +export * from './DescriptionBlock'; diff --git a/public/app/percona/backup/components/DetailedDate/DetailedDate.constants.ts b/public/app/percona/backup/components/DetailedDate/DetailedDate.constants.ts new file mode 100644 index 0000000000000..ea6414c10b95c --- /dev/null +++ b/public/app/percona/backup/components/DetailedDate/DetailedDate.constants.ts @@ -0,0 +1,3 @@ +export const DAY_FORMAT = 'YYYY[-]MM[-]DD'; +export const HOUR_FORMAT = 'HH[:]mm[:]ss'; +export const DATA_QA = 'detailed-date'; diff --git a/public/app/percona/backup/components/DetailedDate/DetailedDate.styles.ts b/public/app/percona/backup/components/DetailedDate/DetailedDate.styles.ts new file mode 100644 index 0000000000000..ab42e126676a6 --- /dev/null +++ b/public/app/percona/backup/components/DetailedDate/DetailedDate.styles.ts @@ -0,0 +1,10 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ typography, colors, spacing }: GrafanaTheme) => ({ + hourWrapper: css` + font-size: ${typography.size.sm}; + color: ${colors.textSemiWeak}; + margin-left: ${spacing.xxs}; + `, +}); diff --git a/public/app/percona/backup/components/DetailedDate/DetailedDate.test.tsx b/public/app/percona/backup/components/DetailedDate/DetailedDate.test.tsx new file mode 100644 index 0000000000000..16f686a02ec35 --- /dev/null +++ b/public/app/percona/backup/components/DetailedDate/DetailedDate.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { DetailedDate } from './DetailedDate'; +import { render, screen } from '@testing-library/react'; + +describe('DetailedDate', () => { + it('should render', () => { + render(); + expect(screen.getByTestId('detailed-date')).toBeInTheDocument(); + expect(screen.getByTestId('detailed-date').children).toHaveLength(2); + }); +}); diff --git a/public/app/percona/backup/components/DetailedDate/DetailedDate.tsx b/public/app/percona/backup/components/DetailedDate/DetailedDate.tsx new file mode 100644 index 0000000000000..ddda8765a5f1b --- /dev/null +++ b/public/app/percona/backup/components/DetailedDate/DetailedDate.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import moment from 'moment/moment'; +import { DetailedDateProps } from './DetailedDate.types'; +import { getStyles } from './DetailedDate.styles'; +import { DAY_FORMAT, HOUR_FORMAT } from './DetailedDate.constants'; + +export const DetailedDate: FC = ({ + date, + dayFormat = DAY_FORMAT, + hourFormat = HOUR_FORMAT, + dataTestId = 'detailed-date', + className, +}) => { + const styles = useStyles(getStyles); + const momentObj = moment(date); + const dayTime = momentObj.format(dayFormat); + const hourTime = momentObj.format(hourFormat); + + return ( + + {dayTime} + {hourTime} + + ); +}; diff --git a/public/app/percona/backup/components/DetailedDate/DetailedDate.types.ts b/public/app/percona/backup/components/DetailedDate/DetailedDate.types.ts new file mode 100644 index 0000000000000..d234b7b766f3a --- /dev/null +++ b/public/app/percona/backup/components/DetailedDate/DetailedDate.types.ts @@ -0,0 +1,7 @@ +export interface DetailedDateProps { + date: number; + dayFormat?: string; + hourFormat?: string; + dataTestId?: string; + className?: string; +} diff --git a/public/app/percona/backup/components/DetailedDate/index.ts b/public/app/percona/backup/components/DetailedDate/index.ts new file mode 100644 index 0000000000000..51c1ab3b3b200 --- /dev/null +++ b/public/app/percona/backup/components/DetailedDate/index.ts @@ -0,0 +1 @@ +export * from './DetailedDate'; diff --git a/public/app/percona/backup/components/KeysBlock/KeysBlock.messages.ts b/public/app/percona/backup/components/KeysBlock/KeysBlock.messages.ts new file mode 100644 index 0000000000000..0263207d21899 --- /dev/null +++ b/public/app/percona/backup/components/KeysBlock/KeysBlock.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + accessKey: 'Access Key', + secretKey: 'Secret Key', +}; diff --git a/public/app/percona/backup/components/KeysBlock/KeysBlock.styles.ts b/public/app/percona/backup/components/KeysBlock/KeysBlock.styles.ts new file mode 100644 index 0000000000000..5db4deceed96d --- /dev/null +++ b/public/app/percona/backup/components/KeysBlock/KeysBlock.styles.ts @@ -0,0 +1,18 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ typography }: GrafanaTheme) => ({ + keysWrapper: css` + display: flex; + flex-direction: column; + justify-content: center; + `, + keyLabel: css` + display: inline-block; + width: 85px; + font-weight: ${typography.weight.semibold}; + `, + secretTogglerWrapper: css` + display: inline-block; + `, +}); diff --git a/public/app/percona/backup/components/KeysBlock/KeysBlock.test.tsx b/public/app/percona/backup/components/KeysBlock/KeysBlock.test.tsx new file mode 100644 index 0000000000000..c6201fa5d882b --- /dev/null +++ b/public/app/percona/backup/components/KeysBlock/KeysBlock.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { KeysBlock } from './KeysBlock'; +import { Messages } from './KeysBlock.messages'; +import { SecretToggler } from 'app/percona/shared/components/Elements/SecretToggler'; +import { render, screen } from '@testing-library/react'; + +jest.mock('app/percona/shared/components/Elements/SecretToggler', () => ({ + SecretToggler: jest.fn(() =>
    ), +})); + +describe('KeysBlock', () => { + it('should have access key next to label', () => { + render(); + expect(screen.getByTestId('access-key')).toHaveTextContent(`${Messages.accessKey}access`); + }); + + it('should have SecretToggler with secret passed', () => { + render(); + expect(SecretToggler).toHaveBeenCalledWith(expect.objectContaining({ secret: 'secret' }), expect.anything()); + }); +}); diff --git a/public/app/percona/backup/components/KeysBlock/KeysBlock.tsx b/public/app/percona/backup/components/KeysBlock/KeysBlock.tsx new file mode 100644 index 0000000000000..ff47c491bd2c9 --- /dev/null +++ b/public/app/percona/backup/components/KeysBlock/KeysBlock.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { SecretToggler } from 'app/percona/shared/components/Elements/SecretToggler'; +import { KeysBlockProps } from './KeysBlock.types'; +import { getStyles } from './KeysBlock.styles'; +import { Messages } from './KeysBlock.messages'; + +export const KeysBlock: FC = ({ accessKey, secretKey }) => { + const styles = useStyles(getStyles); + + return ( +
    +
    + {Messages.accessKey} + {accessKey} +
    +
    + {Messages.secretKey} + + + +
    +
    + ); +}; diff --git a/public/app/percona/backup/components/KeysBlock/KeysBlock.types.ts b/public/app/percona/backup/components/KeysBlock/KeysBlock.types.ts new file mode 100644 index 0000000000000..4763522fa67c1 --- /dev/null +++ b/public/app/percona/backup/components/KeysBlock/KeysBlock.types.ts @@ -0,0 +1,4 @@ +export interface KeysBlockProps { + accessKey: string; + secretKey: string; +} diff --git a/public/app/percona/backup/components/KeysBlock/index.ts b/public/app/percona/backup/components/KeysBlock/index.ts new file mode 100644 index 0000000000000..6960a2a485679 --- /dev/null +++ b/public/app/percona/backup/components/KeysBlock/index.ts @@ -0,0 +1 @@ +export * from './KeysBlock'; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistory.constants.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.constants.ts new file mode 100644 index 0000000000000..d524dc65089c6 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.constants.ts @@ -0,0 +1,2 @@ +export const LIST_RESTORES_CANCEL_TOKEN = 'listRestores'; +export const DATA_INTERVAL = 5000; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistory.service.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.service.ts new file mode 100644 index 0000000000000..3174ef234be02 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.service.ts @@ -0,0 +1,40 @@ +import { api } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { Restore, RestoreResponse } from './RestoreHistory.types'; + +const BASE_URL = '/v1/management/backup'; + +export const RestoreHistoryService = { + async list(token?: CancelToken): Promise { + const { items = [] } = await api.post(`${BASE_URL}/RestoreHistory/List`, {}, false, token); + return items.map( + ({ + restore_id, + artifact_id, + name, + vendor, + location_id, + location_name, + service_id, + service_name, + data_model, + status, + started_at, + finished_at, + }) => ({ + id: restore_id, + artifactId: artifact_id, + name, + vendor, + locationId: location_id, + locationName: location_name, + serviceId: service_id, + serviceName: service_name, + dataModel: data_model, + status, + started: new Date(started_at).getTime(), + finished: finished_at ? new Date(finished_at).getTime() : null, + }) + ); + }, +}; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistory.styles.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.styles.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistory.tsx b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.tsx new file mode 100644 index 0000000000000..cae14c1440f3e --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.tsx @@ -0,0 +1,115 @@ +/* eslint-disable react/display-name */ +import React, { FC, useState, useMemo, useEffect, useCallback } from 'react'; +import { Column, Row } from 'react-table'; +import { logger } from '@percona/platform-core'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { TechnicalPreview } from 'app/percona/shared/components/Elements/TechnicalPreview/TechnicalPreview'; +import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { Table } from 'app/percona/integrated-alerting/components/Table'; +import { ExpandableCell } from 'app/percona/shared/components/Elements/ExpandableCell'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { useRecurringCall } from '../../hooks/recurringCall.hook'; +import { Status } from '../Status'; +import { Messages } from '../../Backup.messages'; +import { DetailedDate } from '../DetailedDate'; +import { Restore } from './RestoreHistory.types'; +import { RestoreHistoryService } from './RestoreHistory.service'; +import { RestoreHistoryDetails } from './RestoreHistoryDetails'; +import { DATA_INTERVAL, LIST_RESTORES_CANCEL_TOKEN } from './RestoreHistory.constants'; + +export const RestoreHistory: FC = () => { + const [pending, setPending] = useState(true); + const [data, setData] = useState([]); + const navModel = usePerconaNavModel('restore-history'); + const [generateToken] = useCancelToken(); + const [triggerTimeout] = useRecurringCall(); + const columns = useMemo( + (): Array> => [ + { + Header: Messages.backupInventory.table.columns.name, + accessor: 'name', + id: 'name', + width: '250px', + Cell: ({ row, value }) => , + }, + { + Header: Messages.backupInventory.table.columns.vendor, + accessor: ({ vendor }: Restore) => DATABASE_LABELS[vendor], + width: '150px', + }, + { + Header: Messages.restoreHistory.table.columns.started, + accessor: 'started', + Cell: ({ value }) => , + }, + { + Header: Messages.backupInventory.table.columns.location, + accessor: 'locationName', + }, + { + Header: Messages.backupInventory.table.columns.status, + accessor: 'status', + Cell: ({ value }) => , + }, + ], + [] + ); + + const renderSelectedSubRow = React.useCallback( + (row: Row) => ( + + ), + [] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('backupEnabled'), []); + + useEffect(() => { + const getData = async (showLoading = false) => { + showLoading && setPending(true); + + try { + const restores = await RestoreHistoryService.list(generateToken(LIST_RESTORES_CANCEL_TOKEN)); + setData(restores); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setPending(false); + }; + + getData(true).then(() => triggerTimeout(getData, DATA_INTERVAL)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + + + + + ); +}; + +export default RestoreHistory; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistory.types.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.types.ts new file mode 100644 index 0000000000000..f0919a511dd94 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistory.types.ts @@ -0,0 +1,20 @@ +import { RestoreStatus } from '../../Backup.types'; +import { Backup, RawBackup } from '../BackupInventory/BackupInventory.types'; + +export interface RawRestore extends Omit { + status: RestoreStatus; + restore_id: string; + started_at: string; + finished_at?: string; +} + +export interface RestoreResponse { + items: RawRestore[]; +} + +export interface Restore extends Omit { + artifactId: string; + started: number; + finished: number | null; + status: RestoreStatus; +} diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.messages.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.messages.ts new file mode 100644 index 0000000000000..6429204f5d887 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + cancelRestore: 'Cancel Restore', + repeatRestore: 'Restore', + deleteRestore: 'Delete Restore record', +}; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.styles.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.styles.ts new file mode 100644 index 0000000000000..e05492bbac588 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.styles.ts @@ -0,0 +1,8 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + actionsWrapper: css` + display: flex; + justify-content: space-around; + `, +}); diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.tsx b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.tsx new file mode 100644 index 0000000000000..d5f9736e224c4 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.tsx @@ -0,0 +1,39 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { DBIcon } from '../../DBIcon'; +import { BackupInventoryActionsProps } from './RestoreHistoryActions.types'; +import { getStyles } from './RestoreHistoryActions.styles'; +import { Messages } from './RestoreHistoryActions.messages'; + +export const RestoreHistoryActions: FC = ({ restore, onCancel, onRestore, onDelete }) => { + const styles = useStyles(getStyles); + const handleCancel = () => onCancel(restore); + const handleRestore = () => onRestore(restore); + const handleDelete = () => onDelete(restore); + + return ( +
    + + + +
    + ); +}; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.types.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.types.ts new file mode 100644 index 0000000000000..9f183c1f05480 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/RestoreHistoryActions.types.ts @@ -0,0 +1,8 @@ +import { Restore } from '../RestoreHistory.types'; + +export interface BackupInventoryActionsProps { + restore: Restore; + onCancel: (restore: Restore) => void; + onRestore: (restore: Restore) => void; + onDelete: (restore: Restore) => void; +} diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/index.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/index.ts new file mode 100644 index 0000000000000..79b272d8f7d74 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryActions/index.ts @@ -0,0 +1 @@ +export * from './RestoreHistoryActions'; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.Messages.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.Messages.ts new file mode 100644 index 0000000000000..684b45ef976e4 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.Messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + backupName: 'Backup name', + finished: 'Finished at', + dataModel: 'Data model', +}; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.styles.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.styles.ts new file mode 100644 index 0000000000000..5df170cb3fc59 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.styles.ts @@ -0,0 +1,16 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + detailsWrapper: css` + display: flex; + + & > span { + flex: 0 1 33.33%; + } + `, + detailLabel: css` + margin-right: ${spacing.md}; + font-weight: ${typography.weight.semibold}; + `, +}); diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.test.tsx b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.test.tsx new file mode 100644 index 0000000000000..fcda2ce2458a6 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { DataModel } from 'app/percona/backup/Backup.types'; +import { RestoreHistoryDetails } from './RestoreHistoryDetails'; +import { render, screen } from '@testing-library/react'; + +const FINISHED_DATE = 1615912580244; +describe('RestoreHistoryDetails', () => { + it('should render', () => { + render(); + expect(screen.getByTestId('restore-details-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('restore-details-name')).toBeInTheDocument(); + expect(screen.getByTestId('restore-details-finished')).toBeInTheDocument(); + expect(screen.getByTestId('restore-details-data-model')).toBeInTheDocument(); + expect(screen.getByTestId('restore-details-date')).toBeInTheDocument(); + }); + + it('should hide "finished at" when null', () => { + render(); + expect(screen.queryByTestId('restore-details-finished')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.tsx b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.tsx new file mode 100644 index 0000000000000..9b36b3116ed4a --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { formatDataModel } from 'app/percona/backup/Backup.utils'; +import { DetailedDate } from '../../DetailedDate'; +import { RestoreHistoryDetailsProps } from './RestoreHistoryDetails.types'; +import { Messages } from './RestoreHistoryDetails.Messages'; +import { getStyles } from './RestoreHistoryDetails.styles'; + +export const RestoreHistoryDetails: FC = ({ name, finished, dataModel }) => { + const styles = useStyles(getStyles); + const dataModelMsg = formatDataModel(dataModel); + + return ( +
    + + {Messages.backupName} {name} + + {finished ? ( + + {Messages.finished} + + + ) : null} + + {Messages.dataModel} {dataModelMsg} + +
    + ); +}; diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.types.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.types.ts new file mode 100644 index 0000000000000..fd155a64c08cf --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/RestoreHistoryDetails.types.ts @@ -0,0 +1,7 @@ +import { DataModel } from 'app/percona/backup/Backup.types'; + +export interface RestoreHistoryDetailsProps { + name: string; + finished: number | null; + dataModel: DataModel; +} diff --git a/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/index.ts b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/index.ts new file mode 100644 index 0000000000000..afd77d17d0722 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/RestoreHistoryDetails/index.ts @@ -0,0 +1 @@ +export * from './RestoreHistoryDetails'; diff --git a/public/app/percona/backup/components/RestoreHistory/__mocks__/RestoreHistory.service.ts b/public/app/percona/backup/components/RestoreHistory/__mocks__/RestoreHistory.service.ts new file mode 100644 index 0000000000000..d044e20901218 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/__mocks__/RestoreHistory.service.ts @@ -0,0 +1,39 @@ +import { DataModel, RestoreStatus } from 'app/percona/backup/Backup.types'; +import { Databases } from 'app/percona/shared/core'; +import * as service from '../RestoreHistory.service'; +import { Restore } from '../RestoreHistory.types'; + +export const stubs: Restore[] = [ + { + id: 'restore_1', + artifactId: 'backup_1', + name: 'Restore 1', + locationId: 'location_1', + locationName: 'Location 1', + started: 1615912580244, + finished: 1615912580244, + serviceId: 'service_1', + serviceName: 'Service 1', + dataModel: DataModel.LOGICAL, + status: RestoreStatus.RESTORE_STATUS_SUCCESS, + vendor: Databases.mysql, + }, + { + id: 'restore_2', + artifactId: 'backup_2', + name: 'Restore 2', + locationId: 'location_2', + locationName: 'Location 2', + started: 1615912580244, + finished: 1615912580244, + serviceId: 'service_2', + serviceName: 'Service 2', + dataModel: DataModel.PHYSICAL, + status: RestoreStatus.RESTORE_STATUS_IN_PROGRESS, + vendor: Databases.mysql, + }, +]; + +export const RestoreHistoryService = jest.genMockFromModule('../RestoreHistory.service') + .RestoreHistoryService; +RestoreHistoryService.list = () => Promise.resolve(stubs); diff --git a/public/app/percona/backup/components/RestoreHistory/index.ts b/public/app/percona/backup/components/RestoreHistory/index.ts new file mode 100644 index 0000000000000..f1545e4481658 --- /dev/null +++ b/public/app/percona/backup/components/RestoreHistory/index.ts @@ -0,0 +1 @@ +export * from './RestoreHistory'; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.constants.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.constants.ts new file mode 100644 index 0000000000000..1a298172b3674 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.constants.ts @@ -0,0 +1 @@ +export const LIST_SCHEDULED_BACKUPS_CANCEL_TOKEN = 'listScheduledBackups'; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.service.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.service.ts new file mode 100644 index 0000000000000..2622c3dc0690d --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.service.ts @@ -0,0 +1,109 @@ +import { CancelToken } from 'axios'; +import { api } from 'app/percona/shared/helpers/api'; +import { ScheduledBackup, ScheduledBackupResponse } from './ScheduledBackups.types'; +import { BackupMode } from '../../Backup.types'; + +const BASE_URL = '/v1/management/backup/Backups'; + +export const ScheduledBackupsService = { + async list(token?: CancelToken): Promise { + const { scheduled_backups = [] } = await api.post( + `${BASE_URL}/ListScheduled`, + {}, + false, + token + ); + + return scheduled_backups.map( + ({ + scheduled_backup_id, + name, + vendor, + start_time, + cron_expression, + location_id, + location_name, + service_id, + service_name, + last_run, + data_model, + description, + retries, + retry_interval, + enabled, + retention = 0, + mode, + }) => ({ + id: scheduled_backup_id, + name, + vendor, + start: new Date(start_time).getTime(), + retention, + cronExpression: cron_expression, + locationId: location_id, + locationName: location_name, + serviceId: service_id, + serviceName: service_name, + lastBackup: last_run ? new Date(last_run).getTime() : undefined, + dataModel: data_model, + description, + mode, + retryTimes: retries, + retryInterval: retry_interval, + enabled: !!enabled, + }) + ); + }, + async schedule( + serviceId: string, + locationId: string, + cronExpression: string, + name: string, + description: string, + retryInterval: string, + retryTimes: number, + retention: number, + enabled: boolean, + mode: BackupMode + ) { + return api.post(`${BASE_URL}/Schedule`, { + service_id: serviceId, + location_id: locationId, + cron_expression: cronExpression, + name, + description, + retry_interval: retryInterval, + retries: retryTimes, + enabled: !!enabled, + retention, + mode, + }); + }, + async toggle(id: string, enabled: boolean) { + return api.post(`${BASE_URL}/ChangeScheduled`, { scheduled_backup_id: id, enabled }); + }, + async delete(id: string) { + return api.post(`${BASE_URL}/RemoveScheduled`, { scheduled_backup_id: id }); + }, + async change( + id: string, + enabled: boolean, + cronExpression: string, + name: string, + description: string, + retryInterval: string, + retryTimes: number, + retention: number + ) { + return api.post(`${BASE_URL}/ChangeScheduled`, { + scheduled_backup_id: id, + enabled, + cron_expression: cronExpression, + name, + description, + retry_interval: retryInterval, + retries: retryTimes, + retention, + }); + }, +}; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.styles.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.styles.ts new file mode 100644 index 0000000000000..023003213fb22 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.styles.ts @@ -0,0 +1,13 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, colors }: GrafanaTheme) => ({ + addWrapper: css` + display: flex; + justify-content: flex-end; + margin-bottom: ${spacing.sm}; + `, + disabledRow: css` + background-color: ${colors.dashboardBg} !important; + `, +}); diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.test.tsx b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.test.tsx new file mode 100644 index 0000000000000..aca52d56a52ca --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { ScheduledBackups } from './ScheduledBackups'; + +jest.mock('./ScheduledBackups.service'); + +describe('ScheduledBackups', () => { + it('should send correct data to Table', async () => { + render( + + + + ); + await screen.findByText('Backup 1'); + expect(screen.getByText('Location 1')).toBeTruthy(); + }); +}); diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx new file mode 100644 index 0000000000000..c8587fdac2254 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.tsx @@ -0,0 +1,357 @@ +/* eslint-disable react/display-name */ +import React, { FC, useState, useMemo, useEffect, useCallback } from 'react'; +import { Cell, Column, Row } from 'react-table'; +import { logger } from '@percona/platform-core'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { TechnicalPreview } from 'app/percona/shared/components/Elements/TechnicalPreview/TechnicalPreview'; +import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { Button, useStyles } from '@grafana/ui'; +import cronstrue from 'cronstrue'; +import { AppEvents } from '@grafana/data'; +import { appEvents } from 'app/core/app_events'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { Table } from 'app/percona/integrated-alerting/components/Table'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { ExpandableCell } from 'app/percona/shared/components/Elements/ExpandableCell'; +import { Messages } from '../../Backup.messages'; +import { DetailedDate } from '../DetailedDate'; +import { AddBackupModal } from '../AddBackupModal'; +import { ScheduledBackup } from './ScheduledBackups.types'; +import { ScheduledBackupsService } from './ScheduledBackups.service'; +import { LIST_SCHEDULED_BACKUPS_CANCEL_TOKEN } from './ScheduledBackups.constants'; +import { ScheduledBackupDetails } from './ScheduledBackupsDetails'; +import { getStyles } from './ScheduledBackups.styles'; +import { AddBackupFormProps } from '../AddBackupModal/AddBackupModal.types'; +import { getCronStringFromValues } from 'app/percona/shared/helpers/cron/cron'; +import { ScheduledBackupsActions } from './ScheduledBackupsActions'; +import { DeleteModal } from 'app/percona/shared/components/Elements/DeleteModal'; +import { RetryMode } from '../../Backup.types'; +import { formatBackupMode } from '../../Backup.utils'; + +export const ScheduledBackups: FC = () => { + const [data, setData] = useState([]); + const [pending, setPending] = useState(false); + const [actionPending, setActionPending] = useState(false); + const [deletePending, setDeletePending] = useState(false); + const [selectedBackup, setSelectedBackup] = useState(null); + const [backupModalVisible, setBackupModalVisible] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const navModel = usePerconaNavModel('scheduled-backups'); + const [generateToken] = useCancelToken(); + const styles = useStyles(getStyles); + + const retentionValue = useCallback((n: number) => { + if (n < 0) { + return ''; + } + + if (n === 0) { + return Messages.scheduledBackups.unlimited; + } + + return `${n} backup${n > 1 ? 's' : ''}`; + }, []); + + const getData = useCallback(async () => { + setPending(true); + try { + const backups = await ScheduledBackupsService.list(generateToken(LIST_SCHEDULED_BACKUPS_CANCEL_TOKEN)); + setData(backups); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setPending(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCopy = useCallback( + async (backup: ScheduledBackup) => { + const { + serviceId, + locationId, + cronExpression, + name, + description, + retention, + retryInterval, + retryTimes, + mode, + } = backup; + const newName = `${Messages.scheduledBackups.copyOf} ${name}`; + setActionPending(true); + try { + await ScheduledBackupsService.schedule( + serviceId, + locationId, + cronExpression, + newName, + description, + retryInterval, + retryTimes, + retention, + false, + mode + ); + getData(); + } catch (e) { + logger.error(e); + } finally { + setActionPending(false); + } + }, + [getData] + ); + + const handleToggle = useCallback( + async ({ id, enabled }: ScheduledBackup) => { + setActionPending(true); + try { + await ScheduledBackupsService.toggle(id, !enabled); + getData(); + } catch (e) { + logger.error(e); + } finally { + setActionPending(false); + } + }, + [getData] + ); + + const columns = useMemo( + (): Array> => [ + { + Header: Messages.scheduledBackups.table.columns.name, + accessor: 'name', + id: 'name', + Cell: ({ row, value }) => , + }, + { + Header: Messages.scheduledBackups.table.columns.vendor, + accessor: 'vendor', + Cell: ({ value }) => DATABASE_LABELS[value], + }, + { + Header: Messages.scheduledBackups.table.columns.frequency, + accessor: 'cronExpression', + Cell: ({ value }) => cronstrue.toString(value, { use24HourTimeFormat: true }), + }, + { + Header: Messages.scheduledBackups.table.columns.retention, + accessor: 'retention', + Cell: ({ value }) => retentionValue(value), + }, + { + Header: Messages.scheduledBackups.table.columns.type, + accessor: 'mode', + Cell: ({ value }) => formatBackupMode(value), + }, + { + Header: Messages.scheduledBackups.table.columns.location, + accessor: 'locationName', + }, + { + Header: Messages.scheduledBackups.table.columns.lastBackup, + accessor: 'lastBackup', + Cell: ({ value }) => (value ? : ''), + }, + { + Header: Messages.scheduledBackups.table.columns.actions, + accessor: 'id', + width: '150px', + Cell: ({ row }) => ( + + ), + }, + ], + [actionPending, handleCopy, handleToggle, retentionValue] + ); + + const renderSelectedSubRow = React.useCallback( + (row: Row) => ( + + ), + [] + ); + + const handleClose = () => { + setBackupModalVisible(false); + }; + + const handleBackup = async (backup: AddBackupFormProps) => { + const { + id, + service, + location, + period, + month, + day, + weekDay, + startHour, + startMinute, + backupName, + description, + retryMode, + retryInterval, + retryTimes, + active, + retention, + mode, + } = backup; + try { + const cronExpression = getCronStringFromValues( + period!.value!, + month!.map((m) => m.value!), + day!.map((m) => m.value!), + weekDay!.map((m) => m.value!), + startHour!.map((m) => m.value!), + startMinute!.map((m) => m.value!) + ); + const strRetryInterval = `${retryInterval}s`; + let resultRetryTimes = retryMode === RetryMode.MANUAL ? 0 : retryTimes; + + if (id) { + await ScheduledBackupsService.change( + id, + active!, + cronExpression, + backupName, + description, + strRetryInterval, + resultRetryTimes!, + retention! + ); + appEvents.emit(AppEvents.alertSuccess, [Messages.scheduledBackups.getEditSuccess(backupName)]); + } else { + await ScheduledBackupsService.schedule( + service!.value?.id!, + location!.value!, + cronExpression, + backupName, + description, + strRetryInterval, + resultRetryTimes!, + retention!, + active!, + mode + ); + appEvents.emit(AppEvents.alertSuccess, [Messages.scheduledBackups.addSuccess]); + } + setBackupModalVisible(false); + setSelectedBackup(null); + getData(); + } catch (e) { + logger.error(e); + } + }; + + const onDeleteClick = (backup: ScheduledBackup) => { + setDeleteModalVisible(true); + setSelectedBackup(backup); + }; + + const handleDelete = async () => { + setDeletePending(true); + try { + await ScheduledBackupsService.delete(selectedBackup?.id!); + appEvents.emit(AppEvents.alertSuccess, [Messages.scheduledBackups.getDeleteSuccess(selectedBackup!.name)]); + setDeleteModalVisible(false); + setSelectedBackup(null); + getData(); + } catch (e) { + logger.error(e); + } finally { + setDeletePending(false); + } + }; + + const onEditClick = (backup: ScheduledBackup) => { + setSelectedBackup(backup); + setBackupModalVisible(true); + }; + + const onAddClick = () => { + setSelectedBackup(null); + setBackupModalVisible(true); + }; + + const getCellProps = useCallback( + (cell: Cell) => ({ + className: !cell.row.original.enabled ? styles.disabledRow : '', + key: cell.row.original.id, + }), + [styles.disabledRow] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('backupEnabled'), []); + + useEffect(() => { + getData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + +
    + +
    +
    + + + + + + ); +}; + +export default ScheduledBackups; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.types.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.types.ts new file mode 100644 index 0000000000000..adb9a25d2d7e4 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackups.types.ts @@ -0,0 +1,46 @@ +import { Databases } from 'app/percona/shared/core'; +import { BackupMode, DataModel } from '../../Backup.types'; + +export interface RawScheduledBackup { + scheduled_backup_id: string; + service_id: string; + service_name: string; + location_id: string; + location_name: string; + cron_expression: string; + start_time: string; + name: string; + description: string; + vendor: Databases; + retry_interval: string; + retries: number; + last_run?: string; + data_model: DataModel; + enabled: boolean; + retention: number; + mode: BackupMode; +} + +export interface ScheduledBackupResponse { + scheduled_backups: RawScheduledBackup[]; +} + +export interface ScheduledBackup { + id: string; + name: string; + locationId: string; + locationName: string; + serviceId: string; + serviceName: string; + vendor: Databases; + retryInterval: string; + retryTimes: number; + start: number; + retention: number; + cronExpression: string; + lastBackup?: number; + dataModel: DataModel; + description: string; + mode: BackupMode; + enabled: boolean; +} diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.styles.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.styles.ts new file mode 100644 index 0000000000000..eaf3e8ce603cc --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.styles.ts @@ -0,0 +1,14 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + actionsWrapper: css` + display: flex; + justify-content: space-between; + align-items: center; + + button { + margin-left: ${spacing.md}; + } + `, +}); diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.tsx b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.tsx new file mode 100644 index 0000000000000..8debc709c9058 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; +import { IconButton, Spinner, Switch, Tooltip, useStyles } from '@grafana/ui'; +import { getStyles } from './ScheduledBackupsActions.styles'; +import { ScheduledBackupsActionsProps } from './ScheduledBackupsActions.types'; + +export const ScheduledBackupsActions: FC = ({ + backup, + onEdit = () => {}, + onCopy = () => {}, + onDelete = () => {}, + onToggle = () => {}, + pending, +}) => { + const styles = useStyles(getStyles); + const handleEdit = () => onEdit(backup); + const handleDelete = () => onDelete(backup); + const handleCopy = () => onCopy(backup); + const handleToggle = () => onToggle(backup); + + return ( +
    + {pending ? ( + + ) : ( + <> + + + + + + + + + + + + )} +
    + ); +}; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.types.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.types.ts new file mode 100644 index 0000000000000..4c26cda67b71a --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/ScheduledBackupsActions.types.ts @@ -0,0 +1,10 @@ +import { ScheduledBackup } from '../ScheduledBackups.types'; + +export interface ScheduledBackupsActionsProps { + backup: ScheduledBackup; + onEdit?: (backup: ScheduledBackup) => void; + onDelete?: (backup: ScheduledBackup) => void; + onCopy?: (backup: ScheduledBackup) => void; + onToggle?: (backup: ScheduledBackup) => void; + pending?: boolean; +} diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/index.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/index.ts new file mode 100644 index 0000000000000..d56b9fd823034 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsActions/index.ts @@ -0,0 +1 @@ +export * from './ScheduledBackupsActions'; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.messages.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.messages.ts new file mode 100644 index 0000000000000..85f2f89eca945 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.messages.ts @@ -0,0 +1,6 @@ +export const Messages = { + backupName: 'Backup name', + description: 'Description', + dataModel: 'Data model', + cronExpression: 'Cron expression', +}; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.styles.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.styles.ts new file mode 100644 index 0000000000000..c1906bea365d5 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.styles.ts @@ -0,0 +1,23 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + detailsWrapper: css` + display: flex; + align-items: center; + flex-wrap: wrap; + margin-top: -${spacing.md}; + + & > * { + flex: 1 0 50%; + + &:not(:last-child) { + margin-top: ${spacing.md}; + } + } + `, + detailLabel: css` + margin-right: ${spacing.md}; + font-weight: ${typography.weight.semibold}; + `, +}); diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.test.tsx b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.test.tsx new file mode 100644 index 0000000000000..d6c21fa8e3722 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { ScheduledBackupDetails } from './ScheduledBackupsDetails'; +import { DataModel } from 'app/percona/backup/Backup.types'; +import { render, screen } from '@testing-library/react'; + +describe('ScheduledBackupsDetails', () => { + it('should render', () => { + render( + + ); + expect(screen.getByTestId('scheduled-backup-details-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('scheduled-backup-details-name')).toBeInTheDocument(); + expect(screen.getByTestId('scheduled-backup-details-description')).toBeInTheDocument(); + expect(screen.getByTestId('scheduled-backup-details-cron')).toBeInTheDocument(); + expect(screen.getByTestId('scheduled-backup-details-data-model')).toBeInTheDocument(); + expect(screen.getByTestId('scheduled-backup-details-data-model')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.tsx b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.tsx new file mode 100644 index 0000000000000..b1c9d935b2322 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { formatDataModel } from 'app/percona/backup/Backup.utils'; +import { ScheduledBackupDetailsProps } from './ScheduledBackupsDetails.types'; +import { Messages } from './ScheduledBackupsDetails.messages'; +import { getStyles } from './ScheduledBackupsDetails.styles'; +import { DescriptionBlock } from '../../DescriptionBlock'; + +export const ScheduledBackupDetails: FC = ({ + name, + description, + dataModel, + cronExpression, +}) => { + const styles = useStyles(getStyles); + const dataModelMsg = formatDataModel(dataModel); + + return ( +
    + + {Messages.backupName} {name} + + + + {Messages.dataModel} {dataModelMsg} + + + {Messages.cronExpression} {cronExpression} + +
    + ); +}; diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.types.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.types.ts new file mode 100644 index 0000000000000..ab567cb34b472 --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/ScheduledBackupsDetails.types.ts @@ -0,0 +1,8 @@ +import { DataModel } from 'app/percona/backup/Backup.types'; + +export interface ScheduledBackupDetailsProps { + name: string; + description: string; + dataModel: DataModel; + cronExpression: string; +} diff --git a/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/index.ts b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/index.ts new file mode 100644 index 0000000000000..c726f889191ad --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/ScheduledBackupsDetails/index.ts @@ -0,0 +1 @@ +export * from './ScheduledBackupsDetails'; diff --git a/public/app/percona/backup/components/ScheduledBackups/__mocks__/ScheduledBackups.service.ts b/public/app/percona/backup/components/ScheduledBackups/__mocks__/ScheduledBackups.service.ts new file mode 100644 index 0000000000000..0b2f6be98159c --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/__mocks__/ScheduledBackups.service.ts @@ -0,0 +1,49 @@ +import { BackupMode, DataModel } from 'app/percona/backup/Backup.types'; +import { Databases } from 'app/percona/shared/core'; +import * as service from '../ScheduledBackups.service'; +import { ScheduledBackup } from '../ScheduledBackups.types'; + +export const stubs: ScheduledBackup[] = [ + { + id: 'backup_1', + name: 'Backup 1', + locationId: 'location_1', + locationName: 'Location 1', + serviceId: 'service_1', + serviceName: 'Service 1', + vendor: Databases.mysql, + start: 1623424776147, + retention: 0, + cronExpression: '30 00 * * *', + lastBackup: 1623424776147, + dataModel: DataModel.PHYSICAL, + description: 'Description', + mode: BackupMode.SNAPSHOT, + enabled: true, + retryInterval: '10s', + retryTimes: 1, + }, + { + id: 'backup_2', + name: 'Backup 2', + locationId: 'location_2', + locationName: 'Location 2', + serviceId: 'service_2', + serviceName: 'Service 2', + vendor: Databases.mysql, + start: 1623424776147, + retention: 0, + cronExpression: '15 00 * * *', + lastBackup: 1623424776147, + dataModel: DataModel.LOGICAL, + description: 'Description', + mode: BackupMode.SNAPSHOT, + enabled: true, + retryInterval: '0s', + retryTimes: 1, + }, +]; + +export const ScheduledBackupsService = jest.genMockFromModule('../ScheduledBackups.service') + .ScheduledBackupsService; +ScheduledBackupsService.list = () => Promise.resolve(stubs); diff --git a/public/app/percona/backup/components/ScheduledBackups/index.ts b/public/app/percona/backup/components/ScheduledBackups/index.ts new file mode 100644 index 0000000000000..2872f5a06146e --- /dev/null +++ b/public/app/percona/backup/components/ScheduledBackups/index.ts @@ -0,0 +1 @@ +export * from './ScheduledBackups'; diff --git a/public/app/percona/backup/components/Status/Status.messages.ts b/public/app/percona/backup/components/Status/Status.messages.ts new file mode 100644 index 0000000000000..135fd7069dcdb --- /dev/null +++ b/public/app/percona/backup/components/Status/Status.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + logs: 'Logs', +}; diff --git a/public/app/percona/backup/components/Status/Status.styles.ts b/public/app/percona/backup/components/Status/Status.styles.ts new file mode 100644 index 0000000000000..0eee189440e3c --- /dev/null +++ b/public/app/percona/backup/components/Status/Status.styles.ts @@ -0,0 +1,26 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ palette }: GrafanaTheme) => ({ + statusContainer: css` + display: flex; + justify-content: space-between; + flex-wrap: wrap; + `, + ellipsisContainer: css` + display: table; + width: 15px; + `, + statusSuccess: css` + color: ${palette.greenBase}; + `, + statusError: css` + color: ${palette.redBase}; + `, + logs: css` + float: right; + color: ${palette.blue77}; + text-decoration: underline; + cursor: pointer; + `, +}); diff --git a/public/app/percona/backup/components/Status/Status.test.tsx b/public/app/percona/backup/components/Status/Status.test.tsx new file mode 100644 index 0000000000000..e5090901675ad --- /dev/null +++ b/public/app/percona/backup/components/Status/Status.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Status } from './Status'; +import { BackupStatus, RestoreStatus } from '../../Backup.types'; +import { Messages } from './Status.messages'; + +jest.mock('app/percona/shared/components/Elements/Icons', () => ({ + ...jest.requireActual('app/percona/shared/components/Elements/Icons'), + Ellipsis: jest.fn(() =>
    ), +})); + +describe('Status', () => { + describe('pending states', () => { + it('should show Ellipsis when backup is pending', () => { + render(); + expect(screen.getByTestId('ellipsis')).toBeInTheDocument(); + expect(screen.queryByTestId('statusMsg')).not.toBeInTheDocument(); + }); + + it('should show Ellipsis when backup is in progress', () => { + render(); + expect(screen.getByTestId('ellipsis')).toBeInTheDocument(); + }); + + it('should show Ellipsis when restore is in progress', () => { + render(); + expect(screen.getByTestId('ellipsis')).toBeInTheDocument(); + }); + }); + + describe('not pending states', () => { + it('should show message when not pending', () => { + render(); + expect(screen.queryByTestId('ellipsis')).not.toBeInTheDocument(); + expect(screen.getByTestId('statusMsg')).toBeInTheDocument(); + }); + }); + + describe('logs action', () => { + it('should have logs hidden by default', () => { + render(); + expect(screen.queryByText(Messages.logs)).not.toBeInTheDocument(); + }); + + it('should call onLogClick', () => { + const onLogClick = jest.fn(); + render(); + userEvent.click(screen.queryByText(Messages.logs)!); + expect(onLogClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/public/app/percona/backup/components/Status/Status.tsx b/public/app/percona/backup/components/Status/Status.tsx new file mode 100644 index 0000000000000..b1cb146845398 --- /dev/null +++ b/public/app/percona/backup/components/Status/Status.tsx @@ -0,0 +1,56 @@ +import React, { FC, useMemo } from 'react'; +import { useTheme } from '@grafana/ui'; +import { cx } from '@emotion/css'; +import { formatStatus } from '../../Backup.utils'; +import { StatusProps } from './Status.types'; +import { getStyles } from './Status.styles'; +import { BackupStatus, RestoreStatus } from '../../Backup.types'; +import { Ellipsis } from 'app/percona/shared/components/Elements/Icons'; +import { Messages } from './Status.messages'; + +const pendingStates = [ + BackupStatus.BACKUP_STATUS_PENDING, + BackupStatus.BACKUP_STATUS_IN_PROGRESS, + RestoreStatus.RESTORE_STATUS_IN_PROGRESS, +]; + +const successfulStates = [BackupStatus.BACKUP_STATUS_SUCCESS, RestoreStatus.RESTORE_STATUS_SUCCESS]; +const errorStates = [ + BackupStatus.BACKUP_STATUS_ERROR, + RestoreStatus.RESTORE_STATUS_ERROR, + BackupStatus.BACKUP_STATUS_INVALID, + RestoreStatus.RESTORE_STATUS_INVALID, +]; + +export const Status: FC = ({ status, showLogsAction = false, onLogClick = () => null }) => { + const statusMsg = formatStatus(status); + const theme = useTheme(); + const styles = getStyles(theme); + const statusStyles = useMemo( + () => ({ + [styles.statusSuccess]: successfulStates.includes(status), + [styles.statusError]: errorStates.includes(status), + }), + [status, styles.statusSuccess, styles.statusError] + ); + const isPending = pendingStates.includes(status); + + return ( +
    + {isPending ? ( + + + + ) : ( + + {statusMsg} + + )} + {showLogsAction && ( + + {Messages.logs} + + )} +
    + ); +}; diff --git a/public/app/percona/backup/components/Status/Status.types.ts b/public/app/percona/backup/components/Status/Status.types.ts new file mode 100644 index 0000000000000..cd3e594fb6a51 --- /dev/null +++ b/public/app/percona/backup/components/Status/Status.types.ts @@ -0,0 +1,7 @@ +import { RestoreStatus, BackupStatus } from '../../Backup.types'; + +export interface StatusProps { + status: BackupStatus | RestoreStatus; + showLogsAction?: boolean; + onLogClick?: () => void; +} diff --git a/public/app/percona/backup/components/Status/index.ts b/public/app/percona/backup/components/Status/index.ts new file mode 100644 index 0000000000000..b230b08b99e81 --- /dev/null +++ b/public/app/percona/backup/components/Status/index.ts @@ -0,0 +1 @@ +export * from './Status'; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocation.utils.test.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocation.utils.test.ts new file mode 100644 index 0000000000000..da2c56d06da6b --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocation.utils.test.ts @@ -0,0 +1,168 @@ +import { LocationType, S3Location, StorageLocation } from '../StorageLocations.types'; +import { toFormStorageLocation, toStorageLocation } from './AddStorageLocation.utils'; +import { AddStorageLocationFormProps } from './AddStorageLocationModal.types'; + +describe('AddStorageLocationUtils', () => { + describe('toFormStorageLocation', () => { + it('should return an empty S3 object by default', () => { + expect(toFormStorageLocation(null)).toEqual({ + locationID: '', + name: '', + description: '', + type: LocationType.S3, + endpoint: '', + client: '', + server: '', + accessKey: '', + secretKey: '', + bucketName: '', + }); + }); + + it('should convert an S3Location', () => { + const location: S3Location = { + locationID: 'Location_1', + name: 'S3', + description: 'S3 location', + path: 's3://foo', + type: LocationType.S3, + accessKey: 'accessKey', + secretKey: 'secretKey', + bucketName: 'bucket', + }; + + expect(toFormStorageLocation(location)).toEqual({ + locationID: location.locationID, + name: location.name, + description: location.description, + type: LocationType.S3, + endpoint: location.path, + accessKey: location.accessKey, + secretKey: location.secretKey, + client: '', + server: '', + bucketName: 'bucket', + }); + }); + + it('should convert a local client location', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'Local Client', + description: 'Client location', + path: '/foo/bar', + type: LocationType.CLIENT, + }; + + expect(toFormStorageLocation(location)).toEqual({ + locationID: location.locationID, + name: location.name, + description: location.description, + type: LocationType.CLIENT, + client: location.path, + endpoint: '', + server: '', + accessKey: '', + secretKey: '', + bucketName: '', + }); + }); + + it('should convert a local server location', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'Local Server', + description: 'Server location', + path: '/foo/bar', + type: LocationType.SERVER, + }; + + expect(toFormStorageLocation(location)).toEqual({ + locationID: location.locationID, + name: location.name, + description: location.description, + type: LocationType.SERVER, + server: location.path, + endpoint: '', + client: '', + accessKey: '', + secretKey: '', + bucketName: '', + }); + }); + }); + + describe('toStorageLocation', () => { + it('should convert an S3 location', () => { + const location: AddStorageLocationFormProps = { + locationID: 'Location1', + name: 'S3 Location', + description: 'location', + type: LocationType.S3, + endpoint: 's3://foo', + client: '', + server: '', + accessKey: 'accessKey', + secretKey: 'secretKey', + bucketName: 'bucket', + }; + + expect(toStorageLocation(location)).toEqual({ + locationID: location.locationID, + name: location.name, + description: location.description, + type: LocationType.S3, + path: location.endpoint, + accessKey: location.accessKey, + secretKey: location.secretKey, + bucketName: location.bucketName, + }); + }); + + it('should convert a local client location', () => { + const location: AddStorageLocationFormProps = { + locationID: 'Location1', + name: 'Client Location', + description: 'client', + type: LocationType.CLIENT, + endpoint: '', + client: '/foo/bar', + server: '', + accessKey: '', + secretKey: '', + bucketName: '', + }; + + expect(toStorageLocation(location)).toEqual({ + locationID: location.locationID, + name: location.name, + description: location.description, + type: LocationType.CLIENT, + path: location.client, + }); + }); + + it('should convert a local server location', () => { + const location: AddStorageLocationFormProps = { + locationID: 'Location1', + name: 'Server Location', + description: 'server', + type: LocationType.SERVER, + endpoint: '', + client: '', + server: '/foo/bar', + accessKey: '', + secretKey: '', + bucketName: '', + }; + + expect(toStorageLocation(location)).toEqual({ + locationID: location.locationID, + name: location.name, + description: location.description, + type: LocationType.SERVER, + path: location.server, + }); + }); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocation.utils.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocation.utils.ts new file mode 100644 index 0000000000000..bbde5431be01d --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocation.utils.ts @@ -0,0 +1,104 @@ +import { StorageLocation, LocationType, S3Location } from '../StorageLocations.types'; +import { AddStorageLocationFormProps } from './AddStorageLocationModal.types'; + +export const toStorageLocation = (values: AddStorageLocationFormProps): StorageLocation => { + const { + name, + description, + type, + endpoint, + client, + server, + accessKey, + secretKey, + bucketName, + locationID = '', + } = values; + const locationMap: Record = { + [LocationType.S3]: { + locationID, + name, + description, + type, + path: endpoint, + accessKey, + secretKey, + bucketName, + }, + [LocationType.CLIENT]: { + locationID, + name, + description, + type, + path: client, + }, + [LocationType.SERVER]: { + locationID, + name, + description, + type, + path: server, + }, + }; + + return locationMap[type]; +}; + +export const toFormStorageLocation = (values: StorageLocation | S3Location | null): AddStorageLocationFormProps => { + if (!values) { + return { + locationID: '', + name: '', + description: '', + type: LocationType.S3, + endpoint: '', + client: '', + server: '', + accessKey: '', + secretKey: '', + bucketName: '', + }; + } + + const { name, description, type, path, locationID } = values; + const locationMap: Record = { + [LocationType.S3]: { + locationID, + name, + description, + type, + endpoint: path, + accessKey: (values as S3Location).accessKey, + secretKey: (values as S3Location).secretKey, + bucketName: (values as S3Location).bucketName, + client: '', + server: '', + }, + [LocationType.CLIENT]: { + locationID, + name, + description, + type, + endpoint: '', + accessKey: '', + secretKey: '', + bucketName: '', + client: path, + server: '', + }, + [LocationType.SERVER]: { + locationID, + name, + description, + type, + endpoint: '', + accessKey: '', + secretKey: '', + bucketName: '', + client: '', + server: path, + }, + }; + + return locationMap[type]; +}; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.constants.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.constants.ts new file mode 100644 index 0000000000000..96cb9079ae432 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.constants.ts @@ -0,0 +1 @@ +export const MAX_NAME_LENGTH = 50; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.messages.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.messages.ts new file mode 100644 index 0000000000000..4bcd9c01849fa --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.messages.ts @@ -0,0 +1,11 @@ +export const Messages = { + addTitle: 'Add Storage Location', + editTitle: 'Edit Storage Location', + name: 'Name', + description: 'Description', + type: 'Type', + addAction: 'Add', + editAction: 'Edit', + cancelAction: 'Cancel', + test: 'Test', +}; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.styles.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.styles.ts new file mode 100644 index 0000000000000..2d7bb1821e7e3 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.styles.ts @@ -0,0 +1,22 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ palette, border }: GrafanaTheme) => ({ + button: css` + min-width: 80px; + justify-content: center; + `, + testButton: css` + background: linear-gradient(180deg, ${palette.greenBase} 0%, ${palette.greenShade} 100%); + border: ${border.width.sm} solid ${palette.greenShade}; + + &:hover, + &:focus { + background: ${palette.greenBase}; + } + + &:focus { + outline: none; + } + `, +}); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.test.tsx b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.test.tsx new file mode 100644 index 0000000000000..f5b275ca660cc --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { LocationType, S3Location, StorageLocation } from '../StorageLocations.types'; +import { AddStorageLocationModal } from './AddStorageLocationModal'; +import { Messages } from './AddStorageLocationModal.messages'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('AddStorageLocationModal', () => { + it('should render local TypeField', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + type: LocationType.CLIENT, + path: '/foo/bar', + }; + render(); + expect(screen.getByTestId('client-field-container')).toBeInTheDocument(); + }); + + it('should render S3 TypeField', () => { + const location: S3Location = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + type: LocationType.S3, + path: '/foo/bar', + accessKey: 'accessKey', + secretKey: 'secretKey', + bucketName: 'bucket', + }; + render(); + expect(screen.queryByTestId('bucketName-field-container')).toBeInTheDocument(); + expect(screen.queryByTestId('accessKey-field-container')).toBeInTheDocument(); + expect(screen.queryByTestId('secretKey-field-container')).toBeInTheDocument(); + expect(screen.queryByTestId('endpoint-field-container')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /endpoint/i })).toHaveValue('/foo/bar'); + }); + + it('should not render unknown type fields', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + path: '', + type: 'unknwon' as LocationType, + }; + render(); + + //S3Fields + expect(screen.queryByTestId('bucketName-field-container')).not.toBeInTheDocument(); + expect(screen.queryByTestId('accessKey-field-container')).not.toBeInTheDocument(); + expect(screen.queryByTestId('secretKey-field-container')).not.toBeInTheDocument(); + expect(screen.queryByTestId('endpoint-field-container')).not.toBeInTheDocument(); + //LocalFields + expect(screen.queryByTestId('server-field-container')).not.toBeInTheDocument(); + expect(screen.queryByTestId('client-field-container')).not.toBeInTheDocument(); + }); + + it('should call onAdd callback', () => { + const onAdd = jest.fn(); + const location: S3Location = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + type: LocationType.S3, + path: '/foo/bar', + accessKey: 'accessKey', + secretKey: 'secretKey', + bucketName: 'bucket', + }; + render(); + + const endpointInput = screen.getByTestId('endpoint-text-input'); + fireEvent.change(endpointInput, { target: { value: 's3://foo' } }); + const form = screen.getByTestId('add-storage-location-modal-form'); + fireEvent.submit(form); + + expect(onAdd).toHaveBeenCalled(); + }); + + it('should show the "Add" button when no location passed', () => { + render(); + expect(screen.getAllByTestId('storage-location-add-button')[0].textContent).toBe(Messages.addAction); + }); + + it('should show the "Edit" button when a location is passed', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + type: LocationType.CLIENT, + path: '/foo/bar', + }; + render(); + expect(screen.getAllByTestId('storage-location-add-button')[0].textContent).toBe(Messages.editAction); + }); + + it('should have the test button', () => { + render(); + + expect(screen.getByTestId('storage-location-test-button')).toBeInTheDocument(); + }); + + it('should disable the test button if the form is invalid', () => { + render(); + const buttons = screen.getAllByTestId('storage-location-test-button'); + expect(buttons[buttons.length - 1]).toBeDisabled(); + }); + + it('should enable the test button if the form is valid', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + type: LocationType.CLIENT, + path: '/foo/bar', + }; + render(); + + const buttons = screen.getAllByTestId('storage-location-test-button'); + expect(buttons[buttons.length - 1]).not.toBeDisabled(); + }); + + it('should disable the add button while waiting for test validation', () => { + const location: StorageLocation = { + locationID: 'Location_1', + name: 'client_fs', + description: 'description', + type: LocationType.CLIENT, + path: '/foo/bar', + }; + render( + + ); + + const buttons = screen.getAllByTestId('storage-location-add-button'); + expect(buttons[buttons.length - 1]).toBeDisabled(); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.tsx b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.tsx new file mode 100644 index 0000000000000..73f05366cd570 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.tsx @@ -0,0 +1,123 @@ +import React, { FC } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { withTypes } from 'react-final-form'; +import { + Modal, + TextInputField, + TextareaInputField, + RadioButtonGroupField, + validators, + LoaderButton, +} from '@percona/platform-core'; +import { Messages } from './AddStorageLocationModal.messages'; +import { + AddStorageLocationFormProps, + AddStorageLocationModalProps, + TypeFieldProps, +} from './AddStorageLocationModal.types'; +import { getStyles } from './AddStorageLocationModal.styles'; +import { MAX_NAME_LENGTH } from './AddStorageLocationModal.constants'; +import { S3Fields } from './S3Fields'; +import { LocalFields } from './LocalFields'; +import { toFormStorageLocation, toStorageLocation } from './AddStorageLocation.utils'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { LocationType } from '../StorageLocations.types'; +import { cx } from '@emotion/css'; + +const TypeField: FC = ({ values }) => { + const { type, client, server, endpoint, accessKey, secretKey, bucketName } = values; + const fieldMap = { + [LocationType.S3]: ( + + ), + [LocationType.SERVER]: , + [LocationType.CLIENT]: , + }; + + return type in fieldMap ? fieldMap[type] : null; +}; + +const typeOptions: Array> = [ + { + value: LocationType.S3, + label: 'S3', + }, + { + value: LocationType.CLIENT, + label: 'Local Client', + }, + { + value: LocationType.SERVER, + label: 'Local Server', + }, +]; + +const { Form } = withTypes(); + +export const AddStorageLocationModal: FC = ({ + isVisible, + location = null, + waitingLocationValidation = false, + onClose = () => null, + onAdd = () => null, + onTest = () => null, +}) => { + const initialValues = toFormStorageLocation(location); + const styles = useStyles(getStyles); + const onSubmit = (values: AddStorageLocationFormProps) => onAdd(toStorageLocation(values)); + const handleTest = (values: AddStorageLocationFormProps) => onTest(toStorageLocation(values)); + + return ( + +
    ( + + + + {/* TODO remove disabled when API allows all three types */} + + + + + {location ? Messages.editAction : Messages.addAction} + + handleTest(values)} + > + {Messages.test} + + + + + )} + /> +
    + ); +}; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.types.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.types.ts new file mode 100644 index 0000000000000..3428855a3249b --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/AddStorageLocationModal.types.ts @@ -0,0 +1,26 @@ +import { LocationType, StorageLocation } from '../StorageLocations.types'; + +export interface AddStorageLocationModalProps { + isVisible: boolean; + location: StorageLocation | null; + waitingLocationValidation?: boolean; + onClose: () => void; + onAdd: (location: StorageLocation) => void; + onTest?: (location: StorageLocation) => void; +} + +export interface AddStorageLocationFormProps { + locationID?: string; + name: string; + description: string; + type: LocationType; + endpoint: string; + client: string; + server: string; + accessKey: string; + secretKey: string; + bucketName: string; +} +export interface TypeFieldProps { + values: AddStorageLocationFormProps; +} diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.messages.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.messages.ts new file mode 100644 index 0000000000000..669f027bd5a13 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + path: 'Path', +}; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.test.tsx b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.test.tsx new file mode 100644 index 0000000000000..ab53c1674b860 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { LocalFields } from './LocalFields'; +import { render, screen } from '@testing-library/react'; +import { Form } from 'react-final-form'; + +describe('LocalFields', () => { + it('should pass initial values', () => { + render(
    } />); + expect(screen.getByRole('textbox')).toHaveValue('/foo'); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.tsx b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.tsx new file mode 100644 index 0000000000000..d451a4450e6eb --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.tsx @@ -0,0 +1,10 @@ +import React, { FC } from 'react'; +import { TextInputField, validators } from '@percona/platform-core'; +import { LocalFieldsProps } from './LocalFields.types'; +import { Messages } from './LocalFields.messages'; + +const required = [validators.required]; + +export const LocalFields: FC = ({ name, path }) => ( + +); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.types.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.types.ts new file mode 100644 index 0000000000000..643866e8d8111 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/LocalFields.types.ts @@ -0,0 +1,4 @@ +export interface LocalFieldsProps { + name: string; + path?: string; +} diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/index.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/index.ts new file mode 100644 index 0000000000000..d69cd942d6173 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/LocalFields/index.ts @@ -0,0 +1 @@ +export * from './LocalFields'; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.Messages.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.Messages.ts new file mode 100644 index 0000000000000..13b792887371d --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.Messages.ts @@ -0,0 +1,6 @@ +export const Messages = { + endpoint: 'Endpoint', + accessKey: 'Access Key', + secretKey: 'Secret Key', + bucketName: 'Bucket Name', +}; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.constants.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.constants.ts new file mode 100644 index 0000000000000..3c052a4694a29 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.constants.ts @@ -0,0 +1 @@ +export const MAX_LENGTH = 200; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.test.tsx b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.test.tsx new file mode 100644 index 0000000000000..28b70167175c5 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { S3Fields } from './S3Fields'; +import { SecretToggler } from 'app/percona/shared/components/Elements/SecretToggler'; +import { Form } from 'react-final-form'; +import { render, screen } from '@testing-library/react'; + +jest.mock('app/percona/shared/components/Elements/SecretToggler', () => ({ + SecretToggler: jest.fn(({ children }) =>
    {children}
    ), +})); + +describe('S3Fields', () => { + it('should pass initial values', () => { + render( + ( + + )} + /> + ); + + const inputs = screen.getAllByRole('textbox').filter((item) => item.tagName === 'INPUT') as HTMLInputElement[]; + expect(inputs.find((item) => item.value === '/foo')).toBeTruthy(); + expect(inputs.find((item) => item.value === 'accessKey')).toBeTruthy(); + expect(inputs.find((item) => item.value === 'bucket')).toBeTruthy(); + expect(SecretToggler).toHaveBeenCalledWith(expect.objectContaining({ secret: 'secretKey' }), expect.anything()); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.tsx b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.tsx new file mode 100644 index 0000000000000..ffaa6e2bcce87 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.tsx @@ -0,0 +1,34 @@ +import React, { FC } from 'react'; +import { TextInputField, validators } from '@percona/platform-core'; +import { S3FieldsProps } from './S3Fields.types'; +import { Messages } from './S3Fields.Messages'; +import { MAX_LENGTH } from './S3Fields.constants'; +import { SecretToggler } from 'app/percona/shared/components/Elements/SecretToggler'; + +const required = [validators.required]; + +export const S3Fields: FC = ({ endpoint, accessKey, secretKey, bucketName }) => ( + <> + + + + + +); diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.types.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.types.ts new file mode 100644 index 0000000000000..ed814cf4e22c5 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/S3Fields.types.ts @@ -0,0 +1,6 @@ +export interface S3FieldsProps { + endpoint?: string; + accessKey?: string; + secretKey?: string; + bucketName?: string; +} diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/index.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/index.ts new file mode 100644 index 0000000000000..62e5f60d582c3 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/S3Fields/index.ts @@ -0,0 +1 @@ +export * from './S3Fields'; diff --git a/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/index.ts b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/index.ts new file mode 100644 index 0000000000000..b23060af2dc3e --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/AddStorageLocationModal/index.ts @@ -0,0 +1 @@ +export * from './AddStorageLocationModal'; diff --git a/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.messages.ts b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.messages.ts new file mode 100644 index 0000000000000..50edfdaf8b516 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.messages.ts @@ -0,0 +1,7 @@ +export const Messages = { + title: 'Delete Storage Location', + deleteLocationWarning: + 'This action will only remove the Storage Location from PMM inventory, but will not delete the physical storage.', + getDeleteMessage: (name: string) => `Are you sure you want to delete the Storage Location "${name}"?`, + force: 'Force', +}; diff --git a/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.test.tsx b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.test.tsx new file mode 100644 index 0000000000000..11b4d7061e893 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { LocationType, StorageLocation } from '../StorageLocations.types'; +import { RemoveStorageLocationModal } from './RemoveStorageLocationModal'; +import { render, screen } from '@testing-library/react'; + +describe('RemoveStorageLocationModal', () => { + it('should have a WarningBlock and CheckboxField', async () => { + const location: StorageLocation = { + locationID: 'ID1', + name: 'Location_1', + description: 'description', + type: LocationType.CLIENT, + path: '/foo', + }; + render( + + ); + + expect(screen.getByTestId('warning-block')).toBeInTheDocument(); + expect(screen.getByTestId('force-checkbox-input')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.tsx b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.tsx new file mode 100644 index 0000000000000..9b81766303092 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; +import { DeleteModal } from 'app/percona/shared/components/Elements/DeleteModal'; +import { WarningBlock } from '../../../../shared/components/Elements/WarningBlock/WarningBlock'; +import { Messages } from './RemoveStorageLocationModal.messages'; +import { RemoveStorageLocationModalProps } from './RemoveStorageLocationModal.types'; + +export const RemoveStorageLocationModal: FC = ({ + location, + isVisible, + loading, + onDelete, + setVisible, +}) => { + const handleDelete = (force = false) => onDelete(location, force); + + return ( + + + + ); +}; diff --git a/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.types.ts b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.types.ts new file mode 100644 index 0000000000000..abe07620692b8 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/RemoveStorageLocationModal.types.ts @@ -0,0 +1,9 @@ +import { StorageLocation } from '../StorageLocations.types'; + +export interface RemoveStorageLocationModalProps { + location: StorageLocation | null; + isVisible: boolean; + loading: boolean; + onDelete: (location: StorageLocation | null, force: boolean) => void; + setVisible: (value: boolean) => void; +} diff --git a/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/index.ts b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/index.ts new file mode 100644 index 0000000000000..635fa95177401 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/RemoveStorageLocationModal/index.ts @@ -0,0 +1 @@ +export * from './RemoveStorageLocationModal'; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.styles.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.styles.ts new file mode 100644 index 0000000000000..998c414f363bc --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.styles.ts @@ -0,0 +1,22 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + wrapper: css` + display: flex; + align-items: center; + + & > * { + word-break: break-all; + flex: 1 0 calc(100% / 3); + + &:not(:last-child) { + padding-right: ${spacing.md}; + } + + &:not(:first-child) { + padding-left: ${spacing.md}; + } + } + `, +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.test.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.test.tsx new file mode 100644 index 0000000000000..58540cf43af37 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { StorageLocationDetails } from './StorageLocationDetails'; +import { LocationType, S3Location, StorageLocation } from '../StorageLocations.types'; +import { KeysBlock } from '../../KeysBlock'; +import { render, screen } from '@testing-library/react'; + +jest.mock('../../KeysBlock', () => ({ + KeysBlock: jest.fn(({ children }) =>
    {children}
    ), +})); + +describe('StorageLocationDetails', () => { + const location: StorageLocation = { + locationID: 'Location1', + path: 'path', + name: 'name', + description: 'description', + type: LocationType.CLIENT, + }; + + it('should have only a DescriptionBlock when not an S3 location', () => { + render(); + + expect(screen.getByTestId('storage-location-wrapper').children).toHaveLength(1); + expect(screen.getByTestId('storage-location-description').querySelector('pre')).toHaveTextContent( + location.description + ); + }); + + it('should have also a KeysBlock when an S3 location', () => { + const s3Location: S3Location = { + ...location, + accessKey: 'access', + secretKey: 'secret', + bucketName: 'bucket', + }; + render(); + expect(screen.getByTestId('keys-block')).toBeInTheDocument(); + + expect(KeysBlock).toHaveBeenCalledWith( + expect.objectContaining({ accessKey: s3Location.accessKey, secretKey: s3Location.secretKey }), + expect.anything() + ); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.tsx new file mode 100644 index 0000000000000..f398fb2ef6896 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { isS3Location } from '../StorageLocations.utils'; +import { DescriptionBlock } from '../../DescriptionBlock'; +import { StorageLocationDetailsProps } from './StorageLocationDetails.types'; +import { getStyles } from './StorageLocationDetails.styles'; +import { KeysBlock } from '../../KeysBlock'; +import { BucketBlock } from '../../BucketBlock'; + +export const StorageLocationDetails: FC = ({ location }) => { + const { description } = location; + const styles = useStyles(getStyles); + + return ( +
    + + {isS3Location(location) ? ( + <> + + + + ) : null} +
    + ); +}; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.types.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.types.ts new file mode 100644 index 0000000000000..539841d08d61e --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/StorageLocationDetails.types.ts @@ -0,0 +1,5 @@ +import { StorageLocation } from '../StorageLocations.types'; + +export interface StorageLocationDetailsProps { + location: StorageLocation; +} diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/index.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/index.ts new file mode 100644 index 0000000000000..46ad3ba96efdb --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationDetails/index.ts @@ -0,0 +1 @@ +export * from './StorageLocationDetails'; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.service.ts b/public/app/percona/backup/components/StorageLocations/StorageLocations.service.ts new file mode 100644 index 0000000000000..70ccb4ada9bda --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.service.ts @@ -0,0 +1,23 @@ +import { api } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { StorageLocationListReponse, StorageLocationReponse } from './StorageLocations.types'; + +const BASE_URL = '/v1/management/backup/Locations'; + +export const StorageLocationsService = { + async list(token?: CancelToken): Promise { + return api.post(`${BASE_URL}/List`, {}); + }, + async add(payload: Partial, token?: CancelToken): Promise { + return api.post(`${BASE_URL}/Add`, payload, false, token); + }, + async update(payload: Partial, token?: CancelToken): Promise { + return api.post(`${BASE_URL}/Change`, payload, false, token); + }, + async testLocation(payload: Partial, token?: CancelToken): Promise { + return api.post(`${BASE_URL}/TestConfig`, payload, false, token); + }, + async delete(locationID: string, force: boolean, token?: CancelToken): Promise { + return api.post(`${BASE_URL}/Remove`, { location_id: locationID, force }); + }, +}; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.styles.ts b/public/app/percona/backup/components/StorageLocations/StorageLocations.styles.ts new file mode 100644 index 0000000000000..bdb862ebd445f --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.styles.ts @@ -0,0 +1,20 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + nameWrapper: css` + display: flex; + justify-content: space-between; + align-items: center; + + & > span { + overflow: hidden; + text-overflow: ellipsis; + } + `, + addWrapper: css` + display: flex; + justify-content: flex-end; + margin-bottom: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.test.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocations.test.tsx new file mode 100644 index 0000000000000..4f4f76af5236c --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { StorageLocationsService } from './StorageLocations.service'; +import { stubLocations } from './__mocks__/StorageLocations.service'; +import { StorageLocations } from './StorageLocations'; + +jest.mock('./StorageLocations.service'); +jest.mock('app/core/app_events'); + +describe('StorageLocations', () => { + it('should show delete modal when icon is clicked', async () => { + render( + + + + ); + + await screen.findByText('first location'); + expect(screen.queryByText('Delete Storage Location')).toBeFalsy(); + + fireEvent.click(screen.getAllByTestId('delete-storage-location-button')[0]); + + expect(screen.getByText('Delete Storage Location')).toBeTruthy(); + }); + + it('should close delete modal after deletion confirmation', async () => { + const spy = spyOn(StorageLocationsService, 'delete').and.callThrough(); + render( + + + + ); + + await screen.findByText('first location'); + + fireEvent.click(screen.getAllByTestId('delete-storage-location-button')[0]); + + expect(screen.getByText('Delete Storage Location')).toBeTruthy(); + fireEvent.submit(screen.getByTestId('confirm-delete-modal-button')); + + await screen.findByText('first location'); + + expect(screen.queryByText('Delete Storage Location')).toBeFalsy(); + expect(spy).toHaveBeenCalledWith(stubLocations.locations[0].location_id, false); + }); + + it('should open the modal by clicking the "Add" button', async () => { + render( + + + + ); + + await screen.findByText('first location'); + + expect(screen.queryByText('Add Storage Location')).toBeFalsy(); + + fireEvent.click(screen.getAllByTestId('storage-location-add-modal-button')[0]); + + expect(screen.getByText('Add Storage Location')).toBeTruthy(); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx new file mode 100644 index 0000000000000..1e62272b4396a --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.tsx @@ -0,0 +1,198 @@ +/* eslint-disable react/display-name */ +import React, { FC, useState, useEffect, useCallback } from 'react'; +import { Column, Row } from 'react-table'; +import { logger } from '@percona/platform-core'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { TechnicalPreview } from 'app/percona/shared/components/Elements/TechnicalPreview/TechnicalPreview'; +import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { Button, useStyles } from '@grafana/ui'; +import { AppEvents } from '@grafana/data'; +import { appEvents } from 'app/core/app_events'; +import { Table } from 'app/percona/integrated-alerting/components/Table'; +import { StorageLocationsActions } from './StorageLocationsActions'; +import { Messages } from '../../Backup.messages'; +import { StorageLocation } from './StorageLocations.types'; +import { StorageLocationsService } from './StorageLocations.service'; +import { formatLocationList, formatToRawLocation } from './StorageLocations.utils'; +import { getStyles } from './StorageLocations.styles'; +import { StorageLocationDetails } from './StorageLocationDetails'; +import { AddStorageLocationModal } from './AddStorageLocationModal'; +import { RemoveStorageLocationModal } from './RemoveStorageLocationModal'; +import { ExpandableCell } from 'app/percona/shared/components/Elements/ExpandableCell'; + +export const StorageLocations: FC = () => { + const [pending, setPending] = useState(true); + const [validatingLocation, setValidatingLocation] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [deletePending, setDeletePending] = useState(false); + const [selectedLocation, setSelectedLocation] = useState(null); + const [data, setData] = useState([]); + const [addModalVisible, setAddModalVisible] = useState(false); + const navModel = usePerconaNavModel('storage-locations'); + const styles = useStyles(getStyles); + const columns = React.useMemo( + (): Array> => [ + { + Header: Messages.storageLocations.table.columns.name, + accessor: 'name', + id: 'name', + width: '315px', + Cell: ({ row, value }) => , + }, + { + Header: Messages.storageLocations.table.columns.type, + accessor: 'type', + width: '150px', + }, + { + Header: Messages.storageLocations.table.columns.path, + accessor: 'path', + }, + { + Header: Messages.storageLocations.table.columns.actions, + accessor: 'locationID', + Cell: ({ row }) => ( + + ), + width: '130px', + }, + ], + [] + ); + + const getData = async () => { + setPending(true); + try { + const rawData = await StorageLocationsService.list(); + setData(formatLocationList(rawData)); + } catch (e) { + logger.error(e); + } finally { + setPending(false); + } + }; + + const renderSelectedSubRow = React.useCallback( + (row: Row) => , + [] + ); + + const onAdd = async (location: StorageLocation) => { + try { + if (location.locationID) { + await StorageLocationsService.update(formatToRawLocation(location)); + appEvents.emit(AppEvents.alertSuccess, [Messages.storageLocations.editSuccess(location.name)]); + } else { + await StorageLocationsService.add(formatToRawLocation(location)); + appEvents.emit(AppEvents.alertSuccess, [Messages.storageLocations.addSuccess]); + } + setAddModalVisible(false); + setSelectedLocation(null); + getData(); + } catch (e) { + logger.error(e); + } + }; + + const handleUpdate = (location: StorageLocation) => { + setSelectedLocation(location); + setAddModalVisible(true); + }; + + const handleTest = async (location: StorageLocation) => { + setValidatingLocation(true); + try { + const rawLocation = formatToRawLocation(location, true); + await StorageLocationsService.testLocation(rawLocation); + appEvents.emit(AppEvents.alertSuccess, [Messages.storageLocations.testSuccess]); + } catch (e) { + logger.error(e); + } finally { + setValidatingLocation(false); + } + }; + + const onDeleteCLick = (location: StorageLocation) => { + setSelectedLocation(location); + setDeleteModalVisible(true); + }; + + const handleDelete = async (location: StorageLocation | null, force: boolean) => { + if (location) { + setDeletePending(true); + try { + await StorageLocationsService.delete(location.locationID, force); + setDeleteModalVisible(false); + setSelectedLocation(null); + appEvents.emit(AppEvents.alertSuccess, [Messages.storageLocations.getDeleteSuccess(location.name)]); + getData(); + } catch (e) { + logger.error(e); + } finally { + setDeletePending(false); + } + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('backupEnabled'), []); + + useEffect(() => { + getData(); + }, []); + + return ( + + + + +
    + +
    +
    + setAddModalVisible(false)} + onAdd={onAdd} + onTest={handleTest} + > + +
    +
    +
    + ); +}; + +export default StorageLocations; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.types.ts b/public/app/percona/backup/components/StorageLocations/StorageLocations.types.ts new file mode 100644 index 0000000000000..ba5914a168c43 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.types.ts @@ -0,0 +1,43 @@ +export enum LocationType { + S3 = 'S3', + CLIENT = 'Local Client', + SERVER = 'Local Server', +} + +export interface StorageLocation { + locationID: string; + name: string; + description: string; + type: LocationType; + path: string; +} + +export interface S3Location extends StorageLocation { + accessKey: string; + secretKey: string; + bucketName: string; +} + +interface S3ConfigResponse { + endpoint: string; + access_key: string; + secret_key: string; + bucket_name: string; +} + +interface FSConfigResponse { + path: string; +} + +export interface StorageLocationReponse { + location_id?: string; + name: string; + description: string; + s3_config?: S3ConfigResponse; + pmm_server_config?: FSConfigResponse; + pmm_client_config?: FSConfigResponse; +} + +export interface StorageLocationListReponse { + locations: StorageLocationReponse[]; +} diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.utils.test.ts b/public/app/percona/backup/components/StorageLocations/StorageLocations.utils.test.ts new file mode 100644 index 0000000000000..89780658f8993 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.utils.test.ts @@ -0,0 +1,103 @@ +import { LocationType, S3Location, StorageLocation, StorageLocationReponse } from './StorageLocations.types'; +import { isS3Location, formatLocationList, formatToRawLocation } from './StorageLocations.utils'; + +const s3Location: S3Location = { + locationID: 'location_1', + bucketName: 'bucket', + name: 's3', + description: 'description', + type: LocationType.S3, + path: 's3://bla', + secretKey: 'secret', + accessKey: 'access', +}; + +const fsLocation: StorageLocation = { + locationID: 'location_2', + name: 'client_fs', + description: 'description', + type: LocationType.CLIENT, + path: '/foo/bar', +}; + +const rawS3Location: StorageLocationReponse = { + location_id: 'location_3', + name: 'first location', + description: 'description_1', + s3_config: { + endpoint: 's3://foo/bar', + access_key: 'access', + secret_key: 'secret', + bucket_name: 'bucket', + }, +}; + +const rawFSLocation: StorageLocationReponse = { + location_id: 'location_4', + name: 'second location', + description: 'description_2', + pmm_server_config: { + path: '/foo/bar', + }, +}; + +describe('StorageLocationsUtils', () => { + it('should infer if location is of S3 type', () => { + expect(isS3Location(s3Location)).toBeTruthy(); + expect(isS3Location(fsLocation)).toBeFalsy(); + }); + + it('should correctly convert raw format to StorageLocation array', () => { + const locations = formatLocationList({ locations: [rawS3Location, rawFSLocation] }); + expect(locations).toHaveLength(2); + expect(locations[0]).toEqual({ + locationID: rawS3Location.location_id, + name: rawS3Location.name, + description: rawS3Location.description, + type: LocationType.S3, + path: rawS3Location.s3_config?.endpoint, + accessKey: rawS3Location.s3_config?.access_key, + secretKey: rawS3Location.s3_config?.secret_key, + bucketName: rawS3Location.s3_config?.bucket_name, + }); + expect(locations[1]).toEqual({ + locationID: rawFSLocation.location_id, + name: rawFSLocation.name, + description: rawFSLocation.description, + type: LocationType.SERVER, + path: rawFSLocation.pmm_server_config?.path, + }); + }); + + it('should correctly convert to raw StorageLocationResponse', () => { + expect(formatToRawLocation(s3Location)).toEqual({ + location_id: s3Location.locationID, + name: s3Location.name, + description: s3Location.description, + s3_config: { + endpoint: s3Location.path, + access_key: s3Location.accessKey, + secret_key: s3Location.secretKey, + bucket_name: s3Location.bucketName, + }, + }); + + expect(formatToRawLocation(fsLocation)).toEqual({ + location_id: fsLocation.locationID, + name: fsLocation.name, + description: fsLocation.description, + pmm_client_config: { + path: fsLocation.path, + }, + }); + + expect(formatToRawLocation({ ...fsLocation, type: LocationType.SERVER })).toEqual({ + location_id: fsLocation.locationID, + name: fsLocation.name, + description: fsLocation.description, + pmm_server_config: { + path: fsLocation.path, + }, + }); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocations.utils.ts b/public/app/percona/backup/components/StorageLocations/StorageLocations.utils.ts new file mode 100644 index 0000000000000..1284dcb23a27f --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocations.utils.ts @@ -0,0 +1,57 @@ +import { + S3Location, + StorageLocation, + StorageLocationListReponse, + LocationType, + StorageLocationReponse, +} from './StorageLocations.types'; + +export const isS3Location = (location: StorageLocation): location is S3Location => 'accessKey' in location; + +export const formatLocationList = (rawList: StorageLocationListReponse): StorageLocation[] => { + const { locations = [] } = rawList; + const parsedLocations: StorageLocation[] = []; + + locations.forEach((location) => { + const { location_id, name, description, pmm_server_config, pmm_client_config, s3_config } = location; + const newLocation: Partial = { name, description, locationID: location_id }; + + if (s3_config) { + const { endpoint, access_key, secret_key, bucket_name } = s3_config; + newLocation.type = LocationType.S3; + newLocation.path = endpoint; + (newLocation as S3Location).accessKey = access_key; + (newLocation as S3Location).secretKey = secret_key; + (newLocation as S3Location).bucketName = bucket_name; + } else if (pmm_server_config) { + newLocation.path = pmm_server_config.path; + newLocation.type = LocationType.SERVER; + } else if (pmm_client_config) { + newLocation.path = pmm_client_config.path; + newLocation.type = LocationType.CLIENT; + } + + parsedLocations.push(newLocation as StorageLocation); + }); + return parsedLocations; +}; + +export const formatToRawLocation = ( + location: StorageLocation | S3Location, + stripOnlyConfig = false +): Partial => { + const { name, description, path, type, locationID } = location; + const localObj = { path }; + const result: Partial = stripOnlyConfig ? {} : { name, description, location_id: locationID }; + + if (isS3Location(location)) { + const { accessKey, secretKey, bucketName } = location; + result.s3_config = { endpoint: path, access_key: accessKey, secret_key: secretKey, bucket_name: bucketName }; + } else if (type === LocationType.CLIENT) { + result.pmm_client_config = localObj; + } else { + result.pmm_server_config = localObj; + } + + return result; +}; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.messages.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.messages.ts new file mode 100644 index 0000000000000..78cec5cfe9ee0 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + editStorageLocation: 'Edit Storage Location', + deleteStorageLocation: 'Delete Storage Location', +}; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.styles.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.styles.ts new file mode 100644 index 0000000000000..e05492bbac588 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.styles.ts @@ -0,0 +1,8 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + actionsWrapper: css` + display: flex; + justify-content: space-around; + `, +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.test.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.test.tsx new file mode 100644 index 0000000000000..cdf1233e9610a --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { StorageLocationsActions } from './StorageLocationsActions'; +import { LocationType, StorageLocation } from '../StorageLocations.types'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('StorageLocationsActions', () => { + it('should have DBIcon', () => { + render( + + ); + expect(screen.getByTestId('edit-storage-location-button')).toBeInTheDocument(); + expect(screen.getByTestId('delete-storage-location-button')).toBeInTheDocument(); + }); + + it('should call onUpdate', () => { + const location: StorageLocation = { + locationID: 'Location1', + name: 'location_1', + description: 'first location', + type: LocationType.S3, + path: 's3://foo', + }; + const handleUpdate = jest.fn(); + render(); + const edit = screen.getByTestId('edit-storage-location-button'); + fireEvent.click(edit); + expect(handleUpdate).toHaveBeenCalledWith(location); + }); +}); diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.tsx b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.tsx new file mode 100644 index 0000000000000..cf2e7b792b5ca --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { DBIcon } from '../../DBIcon'; +import { StorageLocatationsActionProps } from './StorageLocationsActions.types'; +import { getStyles } from './StorageLocationsActions.styles'; +import { Messages } from './StorageLocationsActions.messages'; + +export const StorageLocationsActions: FC = ({ location, onUpdate, onDelete }) => { + const styles = useStyles(getStyles); + + const handleUpdateClick = () => onUpdate(location); + const onDeleteClick = () => onDelete(location); + + return ( +
    + + +
    + ); +}; diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.types.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.types.ts new file mode 100644 index 0000000000000..52ca4db088450 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/StorageLocationsActions.types.ts @@ -0,0 +1,7 @@ +import { StorageLocation } from '../StorageLocations.types'; + +export interface StorageLocatationsActionProps { + location: StorageLocation; + onUpdate: (location: StorageLocation) => void; + onDelete: (location: StorageLocation) => void; +} diff --git a/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/index.ts b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/index.ts new file mode 100644 index 0000000000000..2c02151c528cf --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/StorageLocationsActions/index.ts @@ -0,0 +1 @@ +export * from './StorageLocationsActions'; diff --git a/public/app/percona/backup/components/StorageLocations/__mocks__/StorageLocations.service.ts b/public/app/percona/backup/components/StorageLocations/__mocks__/StorageLocations.service.ts new file mode 100644 index 0000000000000..fe2d3c61aabb0 --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/__mocks__/StorageLocations.service.ts @@ -0,0 +1,39 @@ +import { StorageLocationListReponse } from '../StorageLocations.types'; +import * as service from '../StorageLocations.service'; + +export const stubLocations: StorageLocationListReponse = { + locations: [ + { + location_id: 'location_1', + name: 'first location', + description: 'description_1', + s3_config: { + bucket_name: 'bucket', + endpoint: 's3://foo/bar', + access_key: 'access', + secret_key: 'secret', + }, + }, + { + location_id: 'location_2', + name: 'second location', + description: 'description_2', + pmm_server_config: { + path: '/path/to/server', + }, + }, + { + location_id: 'location_3', + name: 'third location', + description: 'description_3', + pmm_client_config: { + path: '/path/to/client', + }, + }, + ], +}; + +export const StorageLocationsService = jest.genMockFromModule('../StorageLocations.service') + .StorageLocationsService; +StorageLocationsService.list = () => Promise.resolve(stubLocations); +StorageLocationsService.delete = () => Promise.resolve(); diff --git a/public/app/percona/backup/components/StorageLocations/index.ts b/public/app/percona/backup/components/StorageLocations/index.ts new file mode 100644 index 0000000000000..f41c26d604b1c --- /dev/null +++ b/public/app/percona/backup/components/StorageLocations/index.ts @@ -0,0 +1 @@ +export * from './StorageLocations'; diff --git a/public/app/percona/backup/hooks/__mocks__/recurringCall.hook.ts b/public/app/percona/backup/hooks/__mocks__/recurringCall.hook.ts new file mode 100644 index 0000000000000..992e389b82a73 --- /dev/null +++ b/public/app/percona/backup/hooks/__mocks__/recurringCall.hook.ts @@ -0,0 +1 @@ +export const useRecurringCall = jest.fn(() => [jest.fn(), jest.fn(), jest.fn()]); diff --git a/public/app/percona/backup/hooks/recurringCall.hook.test.tsx b/public/app/percona/backup/hooks/recurringCall.hook.test.tsx new file mode 100644 index 0000000000000..867091a81b183 --- /dev/null +++ b/public/app/percona/backup/hooks/recurringCall.hook.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { useRecurringCall } from './recurringCall.hook'; + +let fakeCallback: jest.Mock; +const TIMEOUT_TIME = 5000; +const CHANGED_TIMEOUT_TIME = 20000; + +const Dummy = () => { + const [triggerTimeout, changeInterval, stopTimeout] = useRecurringCall(); + + return ( + <> + + + + + ); +}; + +const originalPlatformCore = jest.requireActual('@percona/platform-core'); +jest.mock('@percona/platform-core', () => ({ + ...originalPlatformCore, + logger: { + error: jest.fn(), + }, +})); + +describe('useRecurringCall', () => { + beforeEach(() => { + jest.useFakeTimers(); + fakeCallback = jest.fn(); + }); + + it('should invoke the callback immediately if flag passed', async () => { + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + await Promise.resolve(); + expect(fakeCallback).toHaveBeenCalledTimes(1); + }); + + it('should invoke the callback recursively', async () => { + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + expect(fakeCallback).toHaveBeenCalledTimes(4); + }); + + it('should clear timeout on unmount', async () => { + const spy = jest.spyOn(window, 'clearTimeout').mockImplementationOnce((args) => clearTimeout(args)); + const wrapper = render(); + fireEvent.click(screen.getAllByRole('button')[0]); + await Promise.resolve(); + expect(spy).not.toHaveBeenCalled(); + wrapper.unmount(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should keep timeout on error', async () => { + fakeCallback.mockImplementationOnce(() => { + throw new Error(); + }); + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + expect(fakeCallback).toHaveBeenCalledTimes(4); + }); + + it('should stop timeout flow', async () => { + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + await Promise.resolve(); + + fireEvent.click(screen.getAllByRole('button')[1]); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + expect(fakeCallback).toHaveBeenCalledTimes(1); + }); + + it('should change interval', async () => { + render(); + fireEvent.click(screen.getAllByRole('button')[0]); + await Promise.resolve(); + expect(fakeCallback).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getAllByRole('button')[2]); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + jest.advanceTimersByTime(5000); + await Promise.resolve(); + + expect(fakeCallback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/public/app/percona/backup/hooks/recurringCall.hook.ts b/public/app/percona/backup/hooks/recurringCall.hook.ts new file mode 100644 index 0000000000000..4e8e54703facc --- /dev/null +++ b/public/app/percona/backup/hooks/recurringCall.hook.ts @@ -0,0 +1,37 @@ +import { logger } from '@percona/platform-core'; +import { useRef, useEffect } from 'react'; + +type hookRecurringCallback = () => void; + +export const useRecurringCall = () => { + const timer = useRef>(); + const interval = useRef(); + + const triggerTimeout = async (cb: hookRecurringCallback, defaultInterval = 10000, callImmediate = false) => { + interval.current = defaultInterval; + try { + callImmediate && (await cb()); + timer.current = setTimeout(async () => { + await cb(); + triggerTimeout(cb, interval.current); + }, interval.current); + } catch (e) { + logger.error(e); + triggerTimeout(cb, interval.current); + } + }; + + const changeInterval = (newInterval: number) => { + interval.current = newInterval; + }; + + const stopTimeout = () => { + timer.current && clearTimeout(timer.current); + }; + + useEffect(() => { + return stopTimeout; + }, []); + + return [triggerTimeout, changeInterval, stopTimeout] as const; +}; diff --git a/public/app/percona/check/Check.context.ts b/public/app/percona/check/Check.context.ts new file mode 100644 index 0000000000000..3160aaa7822af --- /dev/null +++ b/public/app/percona/check/Check.context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import { AlertsReload } from 'app/percona/check/types'; + +export const AlertsReloadContext = createContext({ fetchAlerts: async () => {} }); diff --git a/public/app/percona/check/Check.service.ts b/public/app/percona/check/Check.service.ts new file mode 100644 index 0000000000000..316275d6fba86 --- /dev/null +++ b/public/app/percona/check/Check.service.ts @@ -0,0 +1,149 @@ +import { API, PaginatedFomattedResponse, Severity } from 'app/percona/shared/core'; +import { api } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { + AllChecks, + ChangeCheckBody, + CheckDetails, + CheckResultForServicePayload, + CheckResultSummaryPayload, + FailedCheckSummary, + ServiceFailedCheck, +} from 'app/percona/check/types'; + +import { formatLabels } from '../shared/helpers/labels'; + +export const makeApiUrl: (segment: string) => string = (segment) => `${API.ALERTMANAGER}/${segment}`; +const order = { + [Severity.SEVERITY_EMERGENCY]: 1, + [Severity.SEVERITY_ALERT]: 2, + [Severity.SEVERITY_CRITICAL]: 3, + [Severity.SEVERITY_ERROR]: 4, + [Severity.SEVERITY_WARNING]: 5, + [Severity.SEVERITY_NOTICE]: 6, + [Severity.SEVERITY_INFO]: 7, + [Severity.SEVERITY_DEBUG]: 8, +}; +const BASE_URL = '/v1/management/SecurityChecks'; + +/** + * A service-like object to store the API methods + */ +export const CheckService = { + async getAllFailedChecks(token?: CancelToken): Promise { + const { result = [] } = await api.post( + `${BASE_URL}/ListFailedServices`, + {}, + false, + token + ); + + return result.map( + ({ + service_name, + service_id, + emergency_count = '0', + alert_count = '0', + critical_count = '0', + error_count = '0', + warning_count = '0', + notice_count = '0', + info_count = '0', + debug_count = '0', + }) => ({ + serviceName: service_name, + serviceId: service_id, + counts: { + emergency: parseInt(emergency_count, 10), + alert: parseInt(alert_count, 10), + critical: parseInt(critical_count, 10), + error: parseInt(error_count, 10), + warning: parseInt(warning_count, 10), + notice: parseInt(notice_count, 10), + info: parseInt(info_count, 10), + debug: parseInt(debug_count, 10), + }, + }) + ); + }, + async getFailedCheckForService( + serviceId: string, + pageSize: number, + pageIndex: number, + token?: CancelToken + ): Promise> { + const { + results = [], + page_totals: { total_items: totalItems = 0, total_pages: totalPages = 1 }, + } = await api.post( + `${BASE_URL}/FailedChecks`, + { service_id: serviceId, page_params: { page_size: pageSize, index: pageIndex } }, + false, + token + ); + + return { + totals: { + totalItems, + totalPages, + }, + data: results + .map( + ({ + summary, + description, + severity, + labels = {}, + read_more_url, + service_name, + check_name, + silenced, + alert_id, + }) => ({ + summary, + description, + severity: Severity[severity], + labels: formatLabels(labels), + readMoreUrl: read_more_url, + serviceName: service_name, + checkName: check_name, + silenced: !!silenced, + alertId: alert_id, + }) + ) + .sort((a, b) => order[a.severity] - order[b.severity]), + }; + }, + async silenceAlert(alertId: string, silence: boolean, token?: CancelToken) { + return api.post( + `${BASE_URL}/ToggleCheckAlert`, + { + alert_id: alertId, + silence, + }, + false, + token + ); + }, + runDbChecks(token?: CancelToken): Promise { + return api.post<{}, {}>('/v1/management/SecurityChecks/Start', {}, false, token); + }, + async getAllChecks(token?: CancelToken): Promise { + const response = await api.post('/v1/management/SecurityChecks/List', {}, false, token); + + return response && response.checks ? response.checks.sort((a, b) => a.summary.localeCompare(b.summary)) : []; + }, + runIndividualDbCheck(checkName: string, token?: CancelToken): Promise { + return api.post<{}, {}>( + '/v1/management/SecurityChecks/Start', + { + names: [checkName], + }, + false, + token + ); + }, + changeCheck(body: ChangeCheckBody, token?: CancelToken): Promise { + return api.post<{}, ChangeCheckBody>('/v1/management/SecurityChecks/Change', body, false, token); + }, +}; diff --git a/public/app/percona/check/CheckPanel.constants.ts b/public/app/percona/check/CheckPanel.constants.ts new file mode 100644 index 0000000000000..c23f7f2bdfa74 --- /dev/null +++ b/public/app/percona/check/CheckPanel.constants.ts @@ -0,0 +1,2 @@ +export const PMM_SETTINGS_URL = '/graph/settings/advanced-settings'; +export const GET_SETTINGS_TOKEN = 'getSettings'; diff --git a/public/app/percona/check/CheckPanel.messages.ts b/public/app/percona/check/CheckPanel.messages.ts new file mode 100644 index 0000000000000..10d0a5163ca34 --- /dev/null +++ b/public/app/percona/check/CheckPanel.messages.ts @@ -0,0 +1,9 @@ +export const Messages = { + pageTitle: 'Advisor Checks', + failedChecksTitle: 'Failed', + allChecksTitle: 'All', + silenced: 'Silenced', + advisors: 'Advisor Checks feature', + pmmSettings: 'PMM Settings.', + readMore: 'Read more', +}; diff --git a/public/app/percona/check/CheckPanel.styles.ts b/public/app/percona/check/CheckPanel.styles.ts new file mode 100644 index 0000000000000..526b21206828a --- /dev/null +++ b/public/app/percona/check/CheckPanel.styles.ts @@ -0,0 +1,35 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { palette, colors }, isLight }: GrafanaTheme2) => { + const borderColor = isLight ? palette.gray85 : palette.dark7; + + return { + panel: css` + display: flex; + flex-direction: column; + height: 100%; + `, + spinner: css` + display: flex; + height: 10em; + align-items: center; + justify-content: center; + `, + empty: css` + display: flex; + width: 100%; + height: 160px; + justify-content: center; + align-items: center; + border: 1px solid ${borderColor}; + white-space: pre-wrap; + `, + link: css` + color: ${colors.linkExternal}; + &:hover { + color: ${colors.textBlue}; + } + `, + }; +}; diff --git a/public/app/percona/check/__mocks__/Check.service.ts b/public/app/percona/check/__mocks__/Check.service.ts new file mode 100644 index 0000000000000..53897a66ee68a --- /dev/null +++ b/public/app/percona/check/__mocks__/Check.service.ts @@ -0,0 +1,66 @@ +import { CheckDetails, ServiceFailedCheck } from 'app/percona/check/types'; +import { PaginatedFomattedResponse, Severity } from 'app/percona/shared/core'; + +/** + * A mock version of CheckService + */ +export const CheckService = { + async runDbChecks(): Promise { + return {}; + }, + async getAllChecks(): Promise { + return [ + { + summary: 'Test', + name: 'test enabled', + description: 'test enabled description', + interval: 'STANDARD', + disabled: false, + category: 'performance', + }, + { + summary: 'Test disabled', + name: 'test disabled', + description: 'test disabled description', + interval: 'RARE', + disabled: true, + category: 'security', + }, + ]; + }, + async changeCheck(): Promise { + return {}; + }, + async getFailedCheckForService(): Promise> { + return { + totals: { + totalItems: 2, + totalPages: 1, + }, + data: [ + { + summary: 'first failed check', + description: 'check 1', + severity: Severity.SEVERITY_CRITICAL, + labels: { primary: [], secondary: [] }, + readMoreUrl: 'localhost/check-one', + serviceName: 'Service One', + checkName: 'Check One', + silenced: false, + alertId: 'alert_1', + }, + { + summary: 'second failed check', + description: 'check 2', + severity: Severity.SEVERITY_NOTICE, + labels: { primary: [], secondary: [] }, + readMoreUrl: '', + serviceName: 'Service One', + checkName: 'Check Two', + silenced: false, + alertId: 'alert_2', + }, + ], + }; + }, +}; diff --git a/public/app/percona/check/__mocks__/stubs.ts b/public/app/percona/check/__mocks__/stubs.ts new file mode 100644 index 0000000000000..f1c11bf70fc12 --- /dev/null +++ b/public/app/percona/check/__mocks__/stubs.ts @@ -0,0 +1,204 @@ +import { CheckDetails } from '../types'; + +export const alertsStub = [ + { + annotations: { + description: 'The root password is empty', + summary: 'root password is empty', + read_more_url: 'https://example.com', + }, + endsAt: '2020-04-20T14:39:03.575Z', + fingerprint: '0d2242091d134c09', + receivers: [ + { + name: 'empty', + }, + ], + startsAt: '2020-04-20T12:08:48.946Z', + status: { + inhibitedBy: [], + silencedBy: [], + state: 'active', + }, + updatedAt: '2020-04-20T14:34:03.575Z', + labels: { + stt_check: '1', + agent_id: 'pmm-server', + agent_type: 'pmm-agent', + alertname: 'pmm_agent_outdated', + node_id: 'pmm-server', + node_name: 'pmm-server', + service_name: 'sandbox-mysql.acme.com', + node_type: 'generic', + severity: 'error', + }, + }, + { + annotations: { + description: 'MySQL 5.1 is not the latest major version', + summary: 'MySQL is outdated', + read_more_url: '', + }, + endsAt: '2020-04-20T14:39:03.575Z', + fingerprint: '0d2242091d134c09', + receivers: [ + { + name: 'empty', + }, + ], + startsAt: '2020-04-20T12:08:48.946Z', + status: { + inhibitedBy: [], + silencedBy: [], + state: 'active', + }, + updatedAt: '2020-04-20T14:34:03.575Z', + labels: { + stt_check: '1', + agent_id: 'pmm-server', + agent_type: 'pmm-agent', + alertname: 'pmm_agent_outdated', + node_id: 'pmm-server', + node_name: 'pmm-server', + service_name: 'sandbox-mysql.acme.com', + node_type: 'generic', + severity: 'notice', + }, + }, + { + annotations: { + description: 'PMM Server is not the latest major version', + summary: 'pmm-server is outdated', + read_more_url: '', + }, + endsAt: '2020-04-20T14:39:03.575Z', + fingerprint: '0d2242091d134c09', + receivers: [ + { + name: 'empty', + }, + ], + startsAt: '2020-04-20T12:08:48.946Z', + status: { + inhibitedBy: [], + silencedBy: [], + state: 'active', + }, + updatedAt: '2020-04-20T14:34:03.575Z', + labels: { + stt_check: '1', + agent_id: 'pmm-server', + agent_type: 'pmm-agent', + alertname: 'pmm_agent_outdated', + node_id: 'pmm-server', + node_name: 'pmm-server', + service_name: 'pmm-server-postgresql', + node_type: 'generic', + severity: 'warning', + }, + }, + { + annotations: { + description: 'MongoDB admin password does not meet the complexity requirement', + summary: 'MongoDB password is weak', + read_more_url: '', + }, + endsAt: '2020-04-20T14:39:03.575Z', + fingerprint: '0d2242091d134c09', + receivers: [ + { + name: 'empty', + }, + ], + startsAt: '2020-04-20T12:08:48.946Z', + status: { + inhibitedBy: [], + silencedBy: [], + state: 'active', + }, + updatedAt: '2020-04-20T14:34:03.575Z', + labels: { + stt_check: '1', + agent_id: 'pmm-server', + agent_type: 'pmm-agent', + alertname: 'pmm_agent_outdated', + node_id: 'pmm-server', + node_name: 'pmm-server', + service_name: 'mongodb-inst-rpl-1', + node_type: 'generic', + severity: 'warning', + }, + }, + { + annotations: { + description: 'MongoDB admin password does not meet the complexity requirement', + summary: 'MongoDB password is weak', + read_more_url: '', + }, + endsAt: '2020-04-20T14:39:03.575Z', + fingerprint: '0d2242091d134c09', + receivers: [ + { + name: 'empty', + }, + ], + startsAt: '2020-04-20T12:08:48.946Z', + status: { + inhibitedBy: [], + silencedBy: [], + state: 'suppressed', + }, + updatedAt: '2020-04-20T14:34:03.575Z', + labels: { + stt_check: '1', + agent_id: 'pmm-server', + agent_type: 'pmm-agent', + alertname: 'pmm_agent_outdated', + node_id: 'pmm-server', + node_name: 'pmm-server', + service_name: 'mongodb-inst-rpl-2', + node_type: 'generic', + severity: 'warning', + }, + }, +]; + +export const allChecksStub: CheckDetails[] = [ + { + name: 'test1', + summary: 'Test 1', + description: 'Test number 1', + interval: 'STANDARD', + readMoreUrl: 'https://example.com', + category: '', + }, + { + name: 'test2', + summary: 'Test 2', + description: 'Test number 2', + interval: 'RARE', + category: '', + }, + { + name: 'test3', + summary: 'Test 3', + description: 'Test number 3', + interval: 'STANDARD', + disabled: true, + readMoreUrl: 'https://example.com', + category: '', + }, + { + name: 'test4', + summary: 'Test 4', + interval: 'FREQUENT', + category: '', + }, + { + name: 'test5', + summary: 'Test 5', + disabled: true, + interval: 'STANDARD', + category: '', + }, +]; diff --git a/public/app/percona/check/components/AllChecksTab/AllChecks.context.ts b/public/app/percona/check/components/AllChecksTab/AllChecks.context.ts new file mode 100644 index 0000000000000..711e493e2e734 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/AllChecks.context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import { ChecksReload } from './types'; + +export const ChecksReloadContext = createContext({ fetchChecks: async () => {} }); diff --git a/public/app/percona/check/components/AllChecksTab/AllChecksTab.constants.ts b/public/app/percona/check/components/AllChecksTab/AllChecksTab.constants.ts new file mode 100644 index 0000000000000..acc3b106a4341 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/AllChecksTab.constants.ts @@ -0,0 +1,16 @@ +import { SelectableValue } from '@grafana/data'; +import { Interval } from '../../types'; + +export const GET_ALL_CHECKS_CANCEL_TOKEN = 'getAllChecks'; + +export const STATUS_OPTIONS: Array> = [ + { value: 'all', label: 'All' }, + { value: 'enabled', label: 'Enabled' }, + { value: 'disabled', label: 'Disabled' }, +]; +export const INTERVAL_OPTIONS: Array> = [ + { value: 'all', label: 'All' }, + { value: Interval.FREQUENT, label: Interval.FREQUENT }, + { value: Interval.STANDARD, label: Interval.STANDARD }, + { value: Interval.RARE, label: Interval.RARE }, +]; diff --git a/public/app/percona/check/components/AllChecksTab/AllChecksTab.messages.ts b/public/app/percona/check/components/AllChecksTab/AllChecksTab.messages.ts new file mode 100644 index 0000000000000..165c084b9c575 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/AllChecksTab.messages.ts @@ -0,0 +1,26 @@ +export const Messages = { + disable: 'Disable', + disabled: 'Disabled', + enable: 'Enable', + enabled: 'Enabled', + changeIntervalButtonTitle: 'Change check interval', + table: { + columns: { + name: 'Name', + description: 'Description', + category: 'Category', + status: 'Status', + interval: 'Interval', + actions: 'Actions', + }, + noData: 'No checks founds', + }, + tooltips: { + category: 'Advisor category tags to filter the list of checks (case sensitive). E.g.: performance, security.', + availableSoon: 'Coming soon. This filter will be available with a future PMM release.', + }, + run: 'Run', + checksExecutionStarted: 'All checks started running in the background', + runDbChecks: 'Run Checks', + runIndividualDbCheck: 'check started running in the background', +}; diff --git a/public/app/percona/check/components/AllChecksTab/AllChecksTab.styles.ts b/public/app/percona/check/components/AllChecksTab/AllChecksTab.styles.ts new file mode 100644 index 0000000000000..23ec13cb339cc --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/AllChecksTab.styles.ts @@ -0,0 +1,19 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { spacing } }: GrafanaTheme2) => ({ + descriptionFilter: css` + flex: 1 0 calc(100% - 2 * ${spacing.md}); + `, + actionButtons: css` + display: flex; + flex: 1; + justify-content: flex-end; + padding-bottom: ${spacing.sm}; + align-items: center; + `, + runChecksButton: css` + width: 140px; + justify-content: center; + `, +}); diff --git a/public/app/percona/check/components/AllChecksTab/AllChecksTab.test.tsx b/public/app/percona/check/components/AllChecksTab/AllChecksTab.test.tsx new file mode 100644 index 0000000000000..775c92a79074e --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/AllChecksTab.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { locationService } from '@grafana/runtime'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { configureStore } from 'app/store/configureStore'; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { StoreState } from 'app/types'; +import { logger } from '@percona/platform-core'; +import { CheckService } from 'app/percona/check/Check.service'; +import { Interval } from 'app/percona/check/types'; +import { AllChecksTab } from './AllChecksTab'; +import { Messages } from './AllChecksTab.messages'; + +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); +jest.mock('app/percona/check/Check.service'); + +describe('AllChecksTab::', () => { + beforeEach(() => jest.clearAllMocks()); + it('should fetch checks at startup', async () => { + const spy = jest.spyOn(CheckService, 'getAllChecks'); + render( + + + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + + expect(spy).toBeCalledTimes(1); + }); + + it('should render a spinner at startup, while loading', async () => { + render( + + + + + + ); + + expect(screen.queryByTestId('table-loading')).toBeInTheDocument(); + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(screen.queryByTestId('spinner-wrapper')).not.toBeInTheDocument(); + }); + + it('should render a table', async () => { + render( + + + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + const tbody = screen.getByTestId('table-tbody'); + + expect(tbody.querySelectorAll('tr > td')).toHaveLength(2 * 5); + expect(tbody.querySelectorAll('tr > td')[0]).toHaveTextContent('Test'); + expect(tbody.querySelectorAll('tr > td')[1]).toHaveTextContent('test enabled description'); + expect(tbody.querySelectorAll('tr > td')[2]).toHaveTextContent(Messages.enabled); + expect(tbody.querySelectorAll('tr > td')[3]).toHaveTextContent(Interval.STANDARD); + expect(tbody.querySelectorAll('tr > td')[4]).toHaveTextContent(Messages.disable); + expect(tbody.querySelectorAll('tr > td')[5]).toHaveTextContent('Test disabled'); + expect(tbody.querySelectorAll('tr > td')[6]).toHaveTextContent('test disabled description'); + expect(tbody.querySelectorAll('tr > td')[7]).toHaveTextContent(Messages.disabled); + expect(tbody.querySelectorAll('tr > td')[8]).toHaveTextContent(Interval.RARE); + expect(tbody.querySelectorAll('tr > td')[9]).toHaveTextContent(Messages.enable); + }); + + it('should call an API to change the check status when the action button gets clicked', async () => { + const spy = jest.spyOn(CheckService, 'changeCheck'); + render( + + + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + + const button = screen.getAllByTestId('check-table-loader-button')[0]; + fireEvent.click(button); + + await waitFor(() => expect(button).toHaveTextContent('Enable')); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith({ params: [{ name: 'test enabled', disable: true }] }); + spy.mockClear(); + }); + + it('should log an error if the run checks API call fails', async () => { + jest.spyOn(CheckService, 'runDbChecks').mockImplementationOnce(() => { + throw Error('test'); + }); + const loggerSpy = jest.spyOn(logger, 'error'); + + render( + + + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + + const runChecksButton = screen.getByRole('button', { name: Messages.runDbChecks }); + + await waitFor(() => fireEvent.click(runChecksButton)); + fireEvent.click(runChecksButton); + expect(screen.queryByText('Run Checks')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(loggerSpy).toBeCalledTimes(1); + }); + + expect(await screen.findByText('Run Checks')).toBeInTheDocument(); + }); + + it('should call the API to run checks when the "run checks" button gets clicked', async () => { + const runChecksSpy = jest.spyOn(CheckService, 'runDbChecks'); + render( + + + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + + const runChecksButton = screen.getByRole('button', { name: Messages.runDbChecks }); + + expect(runChecksSpy).toBeCalledTimes(0); + fireEvent.click(runChecksButton); + + expect(screen.queryByText('Run Checks')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(runChecksSpy).toBeCalledTimes(1); + }); + + expect(await screen.findByText('Run Checks')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/check/components/AllChecksTab/AllChecksTab.tsx b/public/app/percona/check/components/AllChecksTab/AllChecksTab.tsx new file mode 100644 index 0000000000000..6e8b1e7d12e39 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/AllChecksTab.tsx @@ -0,0 +1,232 @@ +import React, { FC, useEffect, useState, useCallback, useMemo } from 'react'; +import { useStyles2 } from '@grafana/ui'; +import { AppEvents } from '@grafana/data'; +import { LoaderButton, logger } from '@percona/platform-core'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { ExtendedColumn, FilterFieldTypes, Table } from 'app/percona/integrated-alerting/components/Table'; +import { CheckDetails, Interval } from 'app/percona/check/types'; +import { GET_ALL_CHECKS_CANCEL_TOKEN } from './AllChecksTab.constants'; +import { Messages } from './AllChecksTab.messages'; +import { Messages as mainChecksMessages } from '../../CheckPanel.messages'; +import { FetchChecks } from './types'; +import { CheckActions } from './CheckActions/CheckActions'; +import { ChangeCheckIntervalModal } from './ChangeCheckIntervalModal'; +import { getStyles } from './AllChecksTab.styles'; +import { appEvents } from '../../../../core/app_events'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import Page from 'app/core/components/Page/Page'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { CheckService } from '../../Check.service'; +import { useSocket } from 'app/percona/shared/websockets/WebSocketProvider'; + +export const AllChecksTab: FC = () => { + const [fetchChecksPending, setFetchChecksPending] = useState(false); + const navModel = usePerconaNavModel('all-checks'); + const [generateToken] = useCancelToken(); + const [runChecksPending, setRunChecksPending] = useState(false); + const [checkIntervalModalVisible, setCheckIntervalModalVisible] = useState(false); + const [selectedCheck, setSelectedCheck] = useState(); + const [checks, setChecks] = useState([]); + const styles = useStyles2(getStyles); + const socket = useSocket(); + + const handleRunChecksClick = async () => { + setRunChecksPending(true); + try { + await CheckService.runDbChecks(); + socket.send(JSON.stringify({})); + appEvents.emit(AppEvents.alertSuccess, [Messages.checksExecutionStarted]); + } catch (e) { + logger.error(e); + } finally { + setRunChecksPending(false); + } + }; + + const runIndividualCheck = useCallback( + async (check: CheckDetails) => { + try { + await CheckService.runIndividualDbCheck(check.name); + socket.send(JSON.stringify({ names: [check.name] })); + appEvents.emit(AppEvents.alertSuccess, [`${check.summary} ${Messages.runIndividualDbCheck}`]); + } catch (e) { + logger.error(e); + } finally { + } + }, + [socket] + ); + + const updateUI = (check: CheckDetails) => { + const { name, disabled, interval } = check; + + setChecks((oldChecks) => + oldChecks.map((oldCheck) => { + if (oldCheck.name !== name) { + return oldCheck; + } + + return { ...oldCheck, disabled, interval }; + }) + ); + }; + + const changeCheck = useCallback(async (check: CheckDetails) => { + const action = !!check.disabled ? 'enable' : 'disable'; + try { + await CheckService.changeCheck({ params: [{ name: check.name, [action]: true }] }); + updateUI({ ...check, disabled: !check.disabled }); + } catch (e) { + logger.error(e); + } + }, []); + + const handleIntervalChangeClick = useCallback((check: CheckDetails) => { + setSelectedCheck(check); + setCheckIntervalModalVisible(true); + }, []); + + const handleModalClose = useCallback(() => { + setCheckIntervalModalVisible(false); + setSelectedCheck(undefined); + }, []); + + const handleIntervalChanged = useCallback( + (check: CheckDetails) => { + updateUI({ ...check }); + handleModalClose(); + }, + [handleModalClose] + ); + + const columns = useMemo( + (): Array> => [ + { + Header: Messages.table.columns.name, + accessor: 'summary', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.table.columns.description, + accessor: 'description', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.table.columns.status, + accessor: 'disabled', + Cell: ({ value }) => (!!value ? Messages.disabled : Messages.enabled), + type: FilterFieldTypes.RADIO_BUTTON, + options: [ + { + label: Messages.enabled, + value: false, + }, + { + label: Messages.disabled, + value: true, + }, + ], + }, + { + Header: Messages.table.columns.interval, + accessor: 'interval', + Cell: ({ value }) => Interval[value], + type: FilterFieldTypes.DROPDOWN, + options: [ + { + label: Interval.STANDARD, + value: Interval.STANDARD, + }, + { + label: Interval.RARE, + value: Interval.RARE, + }, + { + label: Interval.FREQUENT, + value: Interval.FREQUENT, + }, + ], + }, + { + Header: Messages.table.columns.actions, + accessor: 'name', + id: 'actions', + // eslint-disable-next-line react/display-name + Cell: ({ row }) => ( + + ), + }, + ], + [changeCheck, handleIntervalChangeClick, runIndividualCheck] + ); + + useEffect(() => { + const fetchChecks: FetchChecks = async () => { + setFetchChecksPending(true); + try { + const checks = await CheckService.getAllChecks(generateToken(GET_ALL_CHECKS_CANCEL_TOKEN)); + + setChecks(checks.map((check) => (!!check.disabled ? check : { ...check, disabled: false }))); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setFetchChecksPending(false); + }; + fetchChecks(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('sttEnabled'), []); + + return ( + + + +
    + + {Messages.runDbChecks} + +
    + + {!!selectedCheck && checkIntervalModalVisible && ( + + )} + + + + ); +}; + +export default AllChecksTab; diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.constants.ts b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.constants.ts new file mode 100644 index 0000000000000..04e7189d9eaab --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.constants.ts @@ -0,0 +1,6 @@ +import { Interval } from 'app/percona/check/types'; + +export const checkIntervalOptions = Object.keys(Interval).map((intervalKey) => ({ + value: intervalKey, + label: Interval[intervalKey as keyof typeof Interval], +})); diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.messages.ts b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.messages.ts new file mode 100644 index 0000000000000..41562b9c99ac2 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.messages.ts @@ -0,0 +1,7 @@ +export const Messages = { + title: 'Set Interval', + cancel: 'Cancel', + save: 'Save', + getSuccess: (checkName: string) => `Interval changed for ${checkName}`, + getDescription: (checkName: string) => `Set interval for ${checkName}`, +}; diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.styles.ts b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.styles.ts new file mode 100644 index 0000000000000..89c36fbb0885d --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme) => ({ + title: css` + margin-bottom: ${theme.spacing.xl}; + `, + content: css` + text-align: center; + word-break: break-word; + `, + intervalRadioWrapper: css` + margin-bottom: ${theme.spacing.lg}; + display: inline-flex; + `, +}); diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.test.tsx b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.test.tsx new file mode 100644 index 0000000000000..fcab471c93a59 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ChangeCheckIntervalModal } from './ChangeCheckIntervalModal'; +import { CheckDetails } from 'app/percona/check/types'; + +jest.mock('../../../Check.service'); +jest.mock('app/core/app_events', () => { + return { + appEvents: { + emit: jest.fn(), + }, + }; +}); + +const TEST_CHECK: CheckDetails = { + summary: 'Test', + name: 'test', + interval: 'STANDARD', + description: 'test description', + disabled: false, + category: '', +}; + +describe('ChangeCheckIntervalModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render modal', () => { + render(); + + expect(screen.getByTestId('modal-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('change-check-interval-form')).toBeInTheDocument(); + expect(screen.getByTestId('change-check-interval-radio-group-wrapper')).toBeInTheDocument(); + }); + + it('should call onClose', () => { + const onClose = jest.fn(); + + render(); + + const modalBackground = screen.getByTestId('modal-background'); + fireEvent.click(modalBackground); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose and onIntervalChanged on submit', async () => { + const onClose = jest.fn(); + const onIntervalChanged = jest.fn(); + + render(); + + const form = screen.getByTestId('change-check-interval-form'); + fireEvent.submit(form); + await waitFor(() => expect(onIntervalChanged).toHaveBeenCalled()); + }); +}); diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.tsx b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.tsx new file mode 100644 index 0000000000000..c3b78ade5520e --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/ChangeCheckIntervalModal.tsx @@ -0,0 +1,77 @@ +import React, { FC } from 'react'; +import { withTypes } from 'react-final-form'; +import { logger, RadioButtonGroupField, LoaderButton, Modal } from '@percona/platform-core'; +import { Messages } from './ChangeCheckIntervalModal.messages'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { AppEvents } from '@grafana/data'; +import { appEvents } from 'app/core/app_events'; +import { CheckService } from 'app/percona/check/Check.service'; +import { getStyles } from './ChangeCheckIntervalModal.styles'; +import { ChangeCheckIntervalModalProps, ChangeCheckIntervalFormValues } from './types'; +import { checkIntervalOptions } from './ChangeCheckIntervalModal.constants'; + +const { Form } = withTypes(); + +export const ChangeCheckIntervalModal: FC = ({ check, onClose, onIntervalChanged }) => { + const styles = useStyles(getStyles); + const { summary, name, interval } = check; + + const changeInterval = async ({ interval }: ChangeCheckIntervalFormValues) => { + try { + await CheckService.changeCheck({ + params: [ + { + name: name, + interval, + }, + ], + }); + appEvents.emit(AppEvents.alertSuccess, [Messages.getSuccess(summary)]); + onIntervalChanged({ ...check, interval: interval! }); + } catch (e) { + logger.error(e); + } + }; + + const initialValues: ChangeCheckIntervalFormValues = { + interval, + }; + + return ( + +
    +

    {Messages.getDescription(summary)}

    + ( + +
    + +
    + + + {Messages.save} + + + + + )} + /> +
    +
    + ); +}; diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/index.ts b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/index.ts new file mode 100644 index 0000000000000..8fd1589cb7fcc --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/index.ts @@ -0,0 +1 @@ +export { ChangeCheckIntervalModal } from './ChangeCheckIntervalModal'; diff --git a/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/types.ts b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/types.ts new file mode 100644 index 0000000000000..9ed23d20cbcf8 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/ChangeCheckIntervalModal/types.ts @@ -0,0 +1,11 @@ +import { Interval, CheckDetails } from 'app/percona/check/types'; + +export interface ChangeCheckIntervalModalProps { + check: CheckDetails; + onClose: () => void; + onIntervalChanged: (check: CheckDetails) => void; +} + +export interface ChangeCheckIntervalFormValues { + interval?: keyof typeof Interval; +} diff --git a/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.styles.ts b/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.styles.ts new file mode 100644 index 0000000000000..21d7b778cdcb5 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.styles.ts @@ -0,0 +1,13 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + actionsWrapper: css` + align-items: center; + justify-content: center; + display: flex; + + > :not(:last-child) { + margin-right: 10px; + } + `, +}); diff --git a/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.tsx b/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.tsx new file mode 100644 index 0000000000000..e816d3844c05a --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.tsx @@ -0,0 +1,55 @@ +import React, { FC, useCallback, useState } from 'react'; +import { IconButton, useStyles2 } from '@grafana/ui'; +import { LoaderButton } from '@percona/platform-core'; +import { Messages } from '../AllChecksTab.messages'; +import { CheckActionsProps } from './CheckActions.types'; +import { getStyles } from './CheckActions.styles'; + +export const CheckActions: FC = ({ + check, + onChangeCheck, + onIntervalChangeClick, + onIndividualRunCheckClick, +}) => { + const styles = useStyles2(getStyles); + const [runCheckPending, setRunCheckPending] = useState(false); + const [intervalChangeLoading, setIntervalChangeLoading] = useState(false); + + const handleChangeCheck = useCallback(async () => { + setIntervalChangeLoading(true); + await onChangeCheck(check); + setIntervalChangeLoading(false); + }, [check, onChangeCheck]); + + const handleIntervalChangeClick = useCallback(() => onIntervalChangeClick(check), [check, onIntervalChangeClick]); + const handleRunIndividualCheckClick = useCallback(async () => { + setRunCheckPending(true); + await onIndividualRunCheckClick(check); + setRunCheckPending(false); + }, [check, onIndividualRunCheckClick]); + + return ( +
    + + {Messages.run} + + + {!!check.disabled ? Messages.enable : Messages.disable} + + +
    + ); +}; diff --git a/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.types.ts b/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.types.ts new file mode 100644 index 0000000000000..2d4f809187ef1 --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/CheckActions/CheckActions.types.ts @@ -0,0 +1,8 @@ +import { CheckDetails } from 'app/percona/check/types'; + +export interface CheckActionsProps { + check: CheckDetails; + onChangeCheck: (check: CheckDetails) => Promise; + onIntervalChangeClick: (check: CheckDetails) => void; + onIndividualRunCheckClick: (check: CheckDetails) => Promise; +} diff --git a/public/app/percona/check/components/AllChecksTab/types.ts b/public/app/percona/check/components/AllChecksTab/types.ts new file mode 100644 index 0000000000000..3b6df4fa2d33f --- /dev/null +++ b/public/app/percona/check/components/AllChecksTab/types.ts @@ -0,0 +1,14 @@ +import { CheckDetails } from 'app/percona/check/types'; + +export type ChangeCheck = (checkName: string, enabled: boolean) => Promise; + +export type FetchChecks = () => Promise; + +export interface CheckTableRowProps { + check: CheckDetails; + onSuccess: (check: CheckDetails) => void; +} + +export interface ChecksReload { + fetchChecks: () => Promise; +} diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.constants.ts b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.constants.ts new file mode 100644 index 0000000000000..b08c74492d9c3 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.constants.ts @@ -0,0 +1,2 @@ +export const GET_ACTIVE_ALERTS_CANCEL_TOKEN = 'getActiveAlerts'; +export const FAILED_CHECKS_TABLE_ID = 'failed-checks'; diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.messages.ts b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.messages.ts new file mode 100644 index 0000000000000..dd00852e98bde --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + showAll: 'Show all', + noChecks: 'No failed checks found', +}; diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.styles.ts b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.styles.ts new file mode 100644 index 0000000000000..2654285123305 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.styles.ts @@ -0,0 +1,23 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { spacing }, colors }: GrafanaTheme2) => ({ + spinner: css` + display: flex; + height: 10em; + align-items: center; + justify-content: center; + `, + row: css` + cursor: pointer; + &:hover { + background: ${colors.action.hover}; + } + `, + cell: css` + background: transparent !important; + `, + contentWrapper: css` + padding: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.test.tsx b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.test.tsx new file mode 100644 index 0000000000000..a0969e7efc2a3 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { logger } from '@percona/platform-core'; +import { StoreState } from 'app/types'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { CheckService } from 'app/percona/check/Check.service'; +import { FailedChecksTab } from './FailedChecksTab'; + +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); + +describe('FailedChecksTab::', () => { + let getAlertsSpy = jest.spyOn(CheckService, 'getAllFailedChecks').mockImplementation(() => Promise.resolve([])); + + afterEach(() => getAlertsSpy.mockClear()); + + it('should fetch active alerts at startup', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(CheckService.getAllFailedChecks).toHaveBeenCalledTimes(1); + }); + + it('should log an error if the fetch alerts API call fails', async () => { + getAlertsSpy.mockImplementationOnce(() => { + throw Error('test'); + }); + const loggerSpy = jest.spyOn(logger, 'error'); + + render( + + + + ); + + expect(loggerSpy).toBeCalledTimes(1); + loggerSpy.mockClear(); + }); + + it('should render a table after having fetched the alerts', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByTestId('table-no-data')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.tsx b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.tsx new file mode 100644 index 0000000000000..68ada06865ccb --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.tsx @@ -0,0 +1,107 @@ +import React, { FC, useEffect, useState, useCallback, useMemo } from 'react'; +import { logger } from '@percona/platform-core'; +import { Cell, Column, Row } from 'react-table'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { ExtendedTableCellProps, ExtendedTableRowProps, Table } from 'app/percona/integrated-alerting/components/Table'; +import { FailedCheckSummary } from 'app/percona/check/types'; +import { AlertsReloadContext } from 'app/percona/check/Check.context'; +import { CheckService } from 'app/percona/check/Check.service'; +import { useStyles2 } from '@grafana/ui'; +import { Messages } from './FailedChecksTab.messages'; +import { Messages as mainChecksMessages } from '../../CheckPanel.messages'; +import { getStyles } from './FailedChecksTab.styles'; +import { stripServiceId } from './FailedChecksTab.utils'; + +import { GET_ACTIVE_ALERTS_CANCEL_TOKEN } from './FailedChecksTab.constants'; +import { locationService } from '@grafana/runtime'; +import { Failures } from './Failures/Failures'; + +export const FailedChecksTab: FC = () => { + const [fetchAlertsPending, setFetchAlertsPending] = useState(true); + const navModel = usePerconaNavModel('failed-checks'); + const [data, setData] = useState([]); + const styles = useStyles2(getStyles); + const [generateToken] = useCancelToken(); + + const columns = useMemo( + (): Array> => [ + { + Header: 'Service Name', + accessor: 'serviceName', + }, + { + Header: 'Fail Count by Severity', + accessor: 'counts', + // eslint-disable-next-line react/display-name + Cell: ({ value }) => , + }, + ], + [] + ); + + const fetchAlerts = useCallback(async (): Promise => { + setFetchAlertsPending(true); + + try { + const checks = await CheckService.getAllFailedChecks(generateToken(GET_ACTIVE_ALERTS_CANCEL_TOKEN)); + setData(checks); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setFetchAlertsPending(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getRowProps = (row: Row): ExtendedTableRowProps => ({ + key: row.original.serviceId, + className: styles.row, + onClick: () => locationService.push(`/pmm-database-checks/failed-checks/${stripServiceId(row.original.serviceId)}`), + }); + + const getCellProps = (cellInfo: Cell): ExtendedTableCellProps => ({ + key: `${cellInfo.row.original.serviceId}-${cellInfo.row.id}`, + className: styles.cell, + }); + + useEffect(() => { + fetchAlerts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('sttEnabled'), []); + + return ( + + + + +
    + + + + + ); +}; + +export default FailedChecksTab; diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.utils.test.ts b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.utils.test.ts new file mode 100644 index 0000000000000..6c6779b6e7345 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.utils.test.ts @@ -0,0 +1,29 @@ +import { stripServiceId, formatServiceId } from './FailedChecksTab.utils'; + +const originalPlatformCore = jest.requireActual('@percona/platform-core'); + +jest.mock('@percona/platform-core', () => ({ + ...originalPlatformCore, + logger: { + error: jest.fn(), + }, +})); + +describe('FailedChecksTab::utils', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('stripServiceId', () => { + expect(stripServiceId('')).toBe(''); + expect(stripServiceId('service_id/service1')).toBe(''); + expect(stripServiceId('/service_idservice1')).toBe(''); + expect(stripServiceId('/service_id/')).toBe(''); + expect(stripServiceId('/service_id/service1')).toBe('service1'); + }); + + test('formatServiceId', () => { + expect(formatServiceId('')).toBe('/service_id/'); + expect(formatServiceId('service1')).toBe('/service_id/service1'); + }); +}); diff --git a/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.utils.ts b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.utils.ts new file mode 100644 index 0000000000000..7be9b72492048 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/FailedChecksTab.utils.ts @@ -0,0 +1,12 @@ +export const stripServiceId = (serviceId: string) => { + const regex = /\/service_id\/(.*)/gm; + const match = regex.exec(serviceId); + + if (match && match.length > 0) { + return match[1] || ''; + } + + return ''; +}; + +export const formatServiceId = (serviceId: string) => `/service_id/${serviceId}`; diff --git a/public/app/percona/check/components/FailedChecksTab/Failures/Failures.styles.ts b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.styles.ts new file mode 100644 index 0000000000000..004fcd1f5bdd4 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.styles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + list: css` + list-style: none; + `, + listItem: css` + display: flex; + justify-content: space-between; + `, +}); diff --git a/public/app/percona/check/components/FailedChecksTab/Failures/Failures.tsx b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.tsx new file mode 100644 index 0000000000000..c952552fe2ab2 --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.tsx @@ -0,0 +1,25 @@ +import { useStyles2 } from '@grafana/ui'; +import { FailedChecksCounts } from 'app/percona/check/types'; +import { Severity } from 'app/percona/integrated-alerting/components/Severity'; +import React, { FC } from 'react'; +import { getStyles } from './Failures.styles'; +import { FailuresProps } from './Failures.types'; +import { failureToSeverity } from './Failures.utils'; + +export const Failures: FC = ({ counts }) => { + const styles = useStyles2(getStyles); + + return ( +
      + {Object.keys(counts).map( + (count) => + counts[count as keyof FailedChecksCounts] > 0 && ( +
    • + {' '} + {counts[count as keyof FailedChecksCounts]} +
    • + ) + )} +
    + ); +}; diff --git a/public/app/percona/check/components/FailedChecksTab/Failures/Failures.types.ts b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.types.ts new file mode 100644 index 0000000000000..8f5107a7bf65b --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.types.ts @@ -0,0 +1,5 @@ +import { FailedChecksCounts } from 'app/percona/check/types'; + +export interface FailuresProps { + counts: FailedChecksCounts; +} diff --git a/public/app/percona/check/components/FailedChecksTab/Failures/Failures.utils.ts b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.utils.ts new file mode 100644 index 0000000000000..389b8e5a0e7da --- /dev/null +++ b/public/app/percona/check/components/FailedChecksTab/Failures/Failures.utils.ts @@ -0,0 +1,25 @@ +import { FailedChecksCounts } from 'app/percona/check/types'; +import { Severity } from 'app/percona/shared/core'; + +export const failureToSeverity = (count: keyof FailedChecksCounts): Severity => { + switch (count) { + case 'emergency': + return Severity.SEVERITY_EMERGENCY; + case 'alert': + return Severity.SEVERITY_ALERT; + case 'critical': + return Severity.SEVERITY_CRITICAL; + case 'error': + return Severity.SEVERITY_ERROR; + case 'warning': + return Severity.SEVERITY_WARNING; + case 'notice': + return Severity.SEVERITY_NOTICE; + case 'info': + return Severity.SEVERITY_INFO; + case 'debug': + return Severity.SEVERITY_DEBUG; + default: + return Severity.SEVERITY_DEBUG; + } +}; diff --git a/public/app/percona/check/components/ServiceChecks/ServiceChecks.constants.ts b/public/app/percona/check/components/ServiceChecks/ServiceChecks.constants.ts new file mode 100644 index 0000000000000..966fbd20e4abc --- /dev/null +++ b/public/app/percona/check/components/ServiceChecks/ServiceChecks.constants.ts @@ -0,0 +1,2 @@ +export const SERVICE_CHECKS_TABLE_ID = 'service-checks'; +export const SERVICE_CHECKS_CANCEL_TOKEN = 'service-checks-token'; diff --git a/public/app/percona/check/components/ServiceChecks/ServiceChecks.messages.ts b/public/app/percona/check/components/ServiceChecks/ServiceChecks.messages.ts new file mode 100644 index 0000000000000..f810f42dab94c --- /dev/null +++ b/public/app/percona/check/components/ServiceChecks/ServiceChecks.messages.ts @@ -0,0 +1,7 @@ +export const Messages = { + noChecks: 'No checks found.', + readMore: 'Read More', + activate: 'Activate', + silence: 'Silence', + pageTitle: (serviceName: string) => `Failed Checks for service "${serviceName}"`, +}; diff --git a/public/app/percona/check/components/ServiceChecks/ServiceChecks.styles.ts b/public/app/percona/check/components/ServiceChecks/ServiceChecks.styles.ts new file mode 100644 index 0000000000000..e609afe162a96 --- /dev/null +++ b/public/app/percona/check/components/ServiceChecks/ServiceChecks.styles.ts @@ -0,0 +1,48 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { colors: v1Colors, spacing }, colors }: GrafanaTheme2) => { + const labelStyles = ` + margin-right: ${spacing.sm}; + margin-bottom: ${spacing.sm}; + word-break: break-word; + `; + const labelContainer = ` + margin-right: -${spacing.sm}; + margin-bottom: -${spacing.sm}; + `; + + return { + link: css` + color: ${v1Colors.linkExternal}; + &:hover { + color: ${v1Colors.textBlue}; + } + `, + chips: css` + ${labelContainer}; + display: flex; + flex-wrap: wrap; + align-items: center; + + & > * { + ${labelStyles} + } + `, + actions: css` + display: flex; + align-items: center; + justify-content: center; + `, + disabledRow: css` + background-color: ${colors.action.disabledBackground} !important; + opacity: ${colors.action.disabledOpacity}; + `, + secondaryLabels: css` + ${labelContainer}; + & > * { + ${labelStyles} + } + `, + }; +}; diff --git a/public/app/percona/check/components/ServiceChecks/ServiceChecks.test.tsx b/public/app/percona/check/components/ServiceChecks/ServiceChecks.test.tsx new file mode 100644 index 0000000000000..c5f26c7ceee50 --- /dev/null +++ b/public/app/percona/check/components/ServiceChecks/ServiceChecks.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { configureStore } from 'app/store/configureStore'; +import { StoreState } from 'app/types'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { ServiceChecks } from './ServiceChecks'; + +jest.mock('app/percona/check/Check.service'); + +describe('ServiceChecks', () => { + it('should show the title with the service name', async () => { + render( + + + + ); + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(screen.getByTestId('page-service')).toHaveTextContent('Failed Checks for service "Service One"'); + }); + + it('should show "Read More" is a link is available', async () => { + render( + + + + ); + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + + const links = screen.getAllByTestId('read-more-link'); + expect(links).toHaveLength(1); + expect(links[0]).toHaveTextContent('Read More'); + expect(links[0]).toHaveAttribute('href', 'localhost/check-one'); + }); +}); diff --git a/public/app/percona/check/components/ServiceChecks/ServiceChecks.tsx b/public/app/percona/check/components/ServiceChecks/ServiceChecks.tsx new file mode 100644 index 0000000000000..6791d39ac7c67 --- /dev/null +++ b/public/app/percona/check/components/ServiceChecks/ServiceChecks.tsx @@ -0,0 +1,173 @@ +/* eslint-disable react/display-name */ +import React, { FC, useEffect, useCallback, useState, useMemo } from 'react'; +import { useStyles2 } from '@grafana/ui'; +import { logger, Chip } from '@percona/platform-core'; +import { Cell, Column, Row } from 'react-table'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { Table } from 'app/percona/integrated-alerting/components/Table'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { useStoredTablePageSize } from 'app/percona/integrated-alerting/components/Table/Pagination'; +import { CheckService } from 'app/percona/check/Check.service'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { ServiceFailedCheck } from '../../types'; +import { SERVICE_CHECKS_CANCEL_TOKEN, SERVICE_CHECKS_TABLE_ID } from './ServiceChecks.constants'; +import { Messages } from './ServiceChecks.messages'; +import { getStyles } from './ServiceChecks.styles'; +import { Severity } from 'app/percona/integrated-alerting/components/Severity'; +import { formatServiceId } from '../FailedChecksTab/FailedChecksTab.utils'; +import { SilenceBell } from 'app/percona/shared/components/Elements/SilenceBell'; +import { ExpandableCell } from 'app/percona/shared/components/Elements/ExpandableCell'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; + +export const ServiceChecks: FC> = ({ match }) => { + const serviceId = formatServiceId(match.params.service); + const [pageSize, setPageSize] = useStoredTablePageSize(SERVICE_CHECKS_TABLE_ID); + const [pageIndex, setPageindex] = useState(0); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [data, setData] = useState([]); + const [pending, setPending] = useState(false); + const [serviceName, setServiceName] = useState(''); + const [generateToken] = useCancelToken(); + const styles = useStyles2(getStyles); + const navModel = usePerconaNavModel('failed-checks'); + + const fetchChecks = useCallback(async () => { + try { + setPending(true); + const { + data, + totals: { totalItems, totalPages }, + } = await CheckService.getFailedCheckForService( + serviceId, + pageSize, + pageIndex, + generateToken(SERVICE_CHECKS_CANCEL_TOKEN) + ); + setData(data); + setServiceName(data[0].serviceName); + setTotalItems(totalItems); + setTotalPages(totalPages); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setPending(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageIndex, pageSize, serviceId]); + + const onSilenceClick = useCallback( + async (alertId: string, silenced: boolean) => { + await CheckService.silenceAlert(alertId, !silenced); + fetchChecks(); + }, + [fetchChecks] + ); + + const columns = useMemo( + (): Array> => [ + { + Header: 'Check Name', + accessor: 'checkName', + Cell: ({ row, value }) => , + }, + { + Header: 'Summary', + accessor: 'summary', + }, + { + Header: 'Description', + accessor: 'description', + }, + { + Header: 'Severity', + accessor: 'severity', + Cell: ({ value }) => , + }, + { + Header: 'Details', + accessor: 'readMoreUrl', + width: '105px', + Cell: ({ value }) => + value ? ( + + {Messages.readMore} + + ) : null, + }, + { + Header: 'Actions', + accessor: 'silenced', + width: '30px', + Cell: ({ value, row }) => ( + + onSilenceClick(row.original.alertId, row.original.silenced)} + /> + + ), + }, + ], + [styles.link, styles.actions, onSilenceClick] + ); + + const onPaginationChanged = useCallback( + (pageSize: number, pageIndex: number) => { + setPageSize(pageSize); + setPageindex(pageIndex); + }, + [setPageindex, setPageSize] + ); + + const getCellProps = useCallback( + (cell: Cell) => ({ + className: !!cell.row.original.silenced ? styles.disabledRow : '', + key: cell.row.original.alertId, + }), + [styles.disabledRow] + ); + + const renderSelectedSubRow = React.useCallback( + (row: Row) => ( +
    + {[...row.original.labels.primary, ...row.original.labels.secondary].map((label) => ( + + ))} +
    + ), + [styles.secondaryLabels] + ); + + useEffect(() => { + fetchChecks(); + }, [fetchChecks]); + + return ( + + +

    {Messages.pageTitle(serviceName)}

    +
    + + + ); +}; + +export default ServiceChecks; diff --git a/public/app/percona/check/components/index.ts b/public/app/percona/check/components/index.ts new file mode 100644 index 0000000000000..0431b24076564 --- /dev/null +++ b/public/app/percona/check/components/index.ts @@ -0,0 +1,2 @@ +export { AllChecksTab } from './AllChecksTab/AllChecksTab'; +export { FailedChecksTab } from './FailedChecksTab/FailedChecksTab'; diff --git a/public/app/percona/check/types.ts b/public/app/percona/check/types.ts new file mode 100644 index 0000000000000..6ddc5c349294b --- /dev/null +++ b/public/app/percona/check/types.ts @@ -0,0 +1,110 @@ +import { PaginatedPayload, PrioritizedLabels, Severity } from '../shared/core/types'; + +export interface CheckResultSummary { + service_name: string; + service_id: string; + // Number of failed checks for this service with severity level "EMERGENCY". + emergency_count: string; + // Number of failed checks for this service with severity level "ALERT". + alert_count: string; + // Number of failed checks for this service with severity level "CRITICAL". + critical_count: string; + // Number of failed checks for this service with severity level "ERROR". + error_count: string; + // Number of failed checks for this service with severity level "WARNING". + warning_count: string; + // Number of failed checks for this service with severity level "NOTICE". + notice_count: string; + // Number of failed checks for this service with severity level "INFO". + info_count: string; + // Number of failed checks for this service with severity level "DEBUG". + debug_count: string; +} + +export interface CheckResultSummaryPayload extends PaginatedPayload { + result: CheckResultSummary[]; +} + +export interface FailedChecksCounts { + emergency: number; + alert: number; + critical: number; + error: number; + warning: number; + notice: number; + info: number; + debug: number; +} + +export interface FailedCheckSummary { + serviceName: string; + serviceId: string; + counts: FailedChecksCounts; +} + +interface CheckResultForService { + summary: string; + description: string; + severity: keyof typeof Severity; + labels: { [key: string]: string }; + read_more_url: string; + service_name: string; + check_name: string; + silenced: boolean; + alert_id: string; +} + +export interface CheckResultForServicePayload extends PaginatedPayload { + results: CheckResultForService[]; +} + +export interface ServiceFailedCheck { + summary: string; + description: string; + severity: Severity; + labels: PrioritizedLabels; + readMoreUrl: string; + serviceName: string; + checkName: string; + silenced: boolean; + alertId: string; +} + +export enum AlertState { + active = 'active', + suppressed = 'suppressed', + unprocessed = 'unprocessed', +} + +export enum Interval { + STANDARD = 'Standard', + RARE = 'Rare', + FREQUENT = 'Frequent', +} + +export interface CheckDetails { + name: string; + summary: string; + interval: keyof typeof Interval; + category: string; + description?: string; + disabled?: boolean; + readMoreUrl?: string; +} + +export interface AllChecks { + checks: CheckDetails[]; +} + +export interface ChangeCheckBody { + params: Array<{ + name: string; + enable?: boolean; + interval?: keyof typeof Interval; + disable?: boolean; + }>; +} + +export interface AlertsReload { + fetchAlerts: () => Promise; +} diff --git a/public/app/percona/dbaas/DBaaS.messages.tsx b/public/app/percona/dbaas/DBaaS.messages.tsx new file mode 100644 index 0000000000000..6fa8f5a92d1a4 --- /dev/null +++ b/public/app/percona/dbaas/DBaaS.messages.tsx @@ -0,0 +1,184 @@ +/* eslint-disable react/display-name */ +import React, { ReactNode } from 'react'; +import { DBClusterStatus } from './components/DBCluster/DBCluster.types'; +import { KubernetesOperatorStatus } from './components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { KubernetesClusterStatus } from './components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.types'; + +export const Messages = { + kubernetes: { + noClusters: 'No clusters found', + deleteAction: 'Unregister', + showConfiguration: 'Show configuration', + manageComponents: 'Manage versions', + addAction: 'Register new Kubernetes Cluster', + deleteModal: { + cancel: 'Cancel', + confirm: 'Proceed', + confirmMessage: 'Are you sure that you want to unregister this cluster?', + title: 'Confirm action', + labels: { + force: 'Ignore errors; unregister anyway', + forceWrapper: 'Force mode', + }, + }, + deleteSuccess: 'Cluster successfully unregistered', + addModal: { + title: 'Register Kubernetes Cluster', + confirm: 'Register', + fields: { + clusterName: 'Kubernetes Cluster Name', + kubeConfig: 'Kubeconfig file', + }, + paste: 'Paste from clipboard', + }, + table: { + nameColumn: 'Kubernetes Cluster Name', + clusterStatusColumn: 'Kubernetes Cluster Status', + operatorsColumn: 'Operators', + actionsColumn: 'Actions', + }, + messages: { + clusterAdded: 'Cluster was successfully registered', + }, + operatorStatus: { + [KubernetesOperatorStatus.ok]: 'Installed', + [KubernetesOperatorStatus.unsupported]: 'Not supported', + [KubernetesOperatorStatus.unavailable]: 'How to install', + [KubernetesOperatorStatus.invalid]: 'Invalid', + errorMessage: 'Cluster creation failed', + getNewVersionAvailable: (version?: string) => `(version ${version} available)`, + }, + kubernetesStatus: { + [KubernetesClusterStatus.ok]: 'Active', + [KubernetesClusterStatus.unavailable]: 'Unavailable', + [KubernetesClusterStatus.invalid]: 'Invalid', + }, + updateOperatorModal: { + cancel: 'Cancel', + confirm: 'Update', + title: 'Confirm operator update', + buildUpdateOperatorMessage: ( + operatorType: string, + newVersion: ReactNode, + kubernetesClusterName: ReactNode, + currentVersion?: string + ) => ( + <> + Are you sure you want to update {operatorType} {currentVersion} to version {newVersion} in{' '} + {kubernetesClusterName} cluster? + + ), + }, + updateOperator: 'Update', + }, + dbcluster: { + addAction: 'Create DB Cluster', + publicAddressWarningBegin: 'If you want to use monitoring, you need to set your PMM installation public address in', + publicAddressWarningLink: 'settings', + publicAddressWarningEnd: 'before cluster creation', + addModal: { + title: 'Create Cluster', + confirm: 'Create Cluster', + fields: { + clusterName: 'Cluster Name', + kubernetesCluster: 'Kubernetes Cluster', + databaseType: 'Database Type', + databaseVersion: 'Database Version', + topology: 'Topology', + nodes: 'Number of Nodes', + resources: 'Resources per Node', + memory: 'Memory (GB)', + cpu: 'CPU', + disk: 'Disk (GB)', + expose: 'External Access', + }, + steps: { + basicOptions: 'Basic Options', + advancedOptions: 'Advanced Options', + }, + topology: { + cluster: 'Cluster', + single: 'Single Node', + }, + resources: { + small: 'Small', + medium: 'Medium', + large: 'Large', + custom: 'Custom', + }, + validationMessages: { + clusterName: 'Should start with a letter, may only contain lower case, number, dash and end with alphanumeric', + notInstalledOperator: 'Operators must be installed to use database type', + requiredField: 'Required field', + }, + noOperatorsMessage: 'No clusters found with installed operators', + resourcesBar: { + memory: 'Memory', + cpu: 'CPU', + disk: 'Disk', + }, + resourcesInfo: 'Resource calculations are an estimate', + exposeTooltip: 'Allows external access to the database cluster', + }, + deleteModal: { + cancel: 'Cancel', + confirm: 'Proceed', + title: 'Confirm action', + }, + editModal: { + confirm: 'Save', + }, + table: { + nameColumn: 'Name', + databaseTypeColumn: 'Database', + connectionColumn: 'Connection', + clusterParametersColumn: 'DB Cluster Parameters', + clusterStatusColumn: 'Cluster Status', + actionsColumn: 'Actions', + connection: { + host: 'Host', + port: 'Port', + username: 'Username', + password: 'Password', + }, + parameters: { + clusterName: 'K8s cluster name', + cpu: 'CPU', + memory: 'Memory', + disk: 'Disk', + expose: { + label: 'External Access', + enabled: 'Enabled', + disabled: 'Disabled', + }, + }, + actions: { + deleteCluster: 'Delete', + editCluster: 'Edit', + restartCluster: 'Restart', + suspend: 'Suspend', + resume: 'Resume', + logs: 'View logs', + updateCluster: 'Update', + }, + status: { + [DBClusterStatus.changing]: 'Pending', + [DBClusterStatus.deleting]: 'Deleting', + [DBClusterStatus.failed]: 'Failed', + [DBClusterStatus.invalid]: 'Invalid', + [DBClusterStatus.ready]: 'Active', + [DBClusterStatus.suspended]: 'Paused', + [DBClusterStatus.upgrading]: 'Updating', + [DBClusterStatus.unknown]: 'Unknown', + errorMessage: 'Cluster creation failed', + logs: 'Logs', + progressError: 'Error', + processing: 'Processing', + complete: 'Complete', + }, + }, + }, + successfulCopyMessage: 'Copied', + copyToClipboard: 'Copy to clipboard', + dbaas: 'DBaaS', +}; diff --git a/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.styles.ts b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.styles.ts new file mode 100644 index 0000000000000..3618696d32032 --- /dev/null +++ b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.styles.ts @@ -0,0 +1,9 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + addClusterButtonWrapper: css` + align-items: center; + display: flex; + flex-direction: column; + `, +}); diff --git a/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.test.tsx b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.test.tsx new file mode 100644 index 0000000000000..9716a41cadf58 --- /dev/null +++ b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { AddClusterButton } from './AddClusterButton'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('AddClusterButton::', () => { + it('renders correctly and calls action', () => { + const action = jest.fn(); + render(); + const btn = screen.getByRole('button'); + fireEvent.click(btn); + + expect(screen.getByRole('button')).toHaveTextContent('test'); + expect(action).toHaveBeenCalled(); + }); + + it('disables button correctly', () => { + const action = jest.fn(); + render(); + const btn = screen.getByRole('button'); + fireEvent.click(btn); + + expect(action).not.toHaveBeenCalled(); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.tsx b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.tsx new file mode 100644 index 0000000000000..9242ca658cf96 --- /dev/null +++ b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import { Button, useStyles } from '@grafana/ui'; +import { AddClusterButtonProps } from './AddClusterButton.types'; +import { getStyles } from './AddClusterButton.styles'; + +export const AddClusterButton: FC = ({ label, disabled, action, ...props }) => { + const styles = useStyles(getStyles); + + return ( +
    + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.types.ts b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.types.ts new file mode 100644 index 0000000000000..6b9a25c460a27 --- /dev/null +++ b/public/app/percona/dbaas/components/AddClusterButton/AddClusterButton.types.ts @@ -0,0 +1,5 @@ +export interface AddClusterButtonProps { + label: string; + disabled?: boolean; + action: () => void; +} diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.styles.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.styles.ts new file mode 100644 index 0000000000000..5d246bae1bbd5 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.styles.ts @@ -0,0 +1,38 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing, typography, colors, palette }: GrafanaTheme) => ({ + modalWrapper: css` + div[data-testid='modal-body'] { + width: 60%; + max-width: none; + } + `, + stepProgressWrapper: css` + overflow: hidden; + + div[class$='-current'] { + overflow: auto; + } + `, + warningIcon: css` + fill: ${palette.brandDanger}; + height: 30px; + width: 30px; + margin-right: ${spacing.sm}; + `, + settingsLink: css` + color: ${colors.linkExternal}; + &:hover { + color: ${colors.linkExternal}; + } + `, + warningWrapper: css` + align-items: center; + display: flex; + `, + warningMessage: css` + font-size: ${typography.size.md}; + margin-left: ${spacing.xs}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.test.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.test.tsx new file mode 100644 index 0000000000000..eaeff3e3ebd7f --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { AddDBClusterModal } from './AddDBClusterModal'; +import { onDBClusterAddedStub, setVisibleStub } from './__mocks__/addDBClusterModalStubs'; +import { kubernetesStub } from '../../Kubernetes/__mocks__/kubernetesStubs'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { StoreState } from 'app/types'; +import { Provider } from 'react-redux'; +import { configureStore } from 'app/store/configureStore'; +import { Router } from 'react-router-dom'; +import { locationService } from '@grafana/runtime'; +import { updateDatabaseClusterNameInitialValue } from './AddDBClusterModal.utils'; + +jest.mock('./AddDBClusterModal.utils', () => ({ + ...jest.requireActual('./AddDBClusterModal.utils'), + updateDatabaseClusterNameInitialValue: jest.fn(), +})); + +jest.mock('app/core/app_events'); + +describe('AddDBClusterModal::', () => { + const openStep = (step: string) => { + const stepNode = screen.getByTestId(`${step}`).querySelector('[data-testid="step-header"]'); + if (stepNode) { + fireEvent.click(stepNode); + } + }; + + const isStepActive = (step: string) => { + const stepNode = screen.getByTestId(`${step}`).querySelector('[data-testid="step-content"]'); + return stepNode ? stepNode.getElementsByTagName('div')[0].className.split('-')?.includes('current') : false; + }; + + it('renders correctly', () => { + render( + + + + + + ); + + expect(screen.findByRole('form')).toBeTruthy(); + expect(screen.getByTestId('name-text-input')).toBeTruthy(); + expect(screen.getByTestId('dbcluster-kubernetes-cluster-field')).toBeTruthy(); + expect(screen.getByTestId('dbcluster-database-type-field')).toBeTruthy(); + expect(screen.getByTestId('step-progress-submit-button')).toBeTruthy(); + expect(screen.getByTestId('dbcluster-basic-options-step')).toBeTruthy(); + expect(screen.getByTestId('dbcluster-advanced-options-step')).toBeTruthy(); + expect(screen.getByTestId('dbcluster-advanced-options-step')).toBeTruthy(); + expect(screen.findByTestId('add-cluster-monitoring-warning')).toBeTruthy(); + }); + + it('should disable submit button when there is no values', () => { + render( + + + + + + ); + + openStep('dbcluster-advanced-options-step'); + + const button = screen.getByTestId('step-progress-submit-button'); + expect(button).toBeDisabled(); + }); + + it('should change step correctly', () => { + render( + + + + + + ); + + expect(isStepActive('dbcluster-basic-options-step')).toBeTruthy(); + openStep('dbcluster-advanced-options-step'); + expect(isStepActive('dbcluster-advanced-options-step')).toBeTruthy(); + expect(isStepActive('dbcluster-basic-options-step')).toBeFalsy(); + }); + + it('form should have default values', () => { + render( + + + + + + ); + + expect(updateDatabaseClusterNameInitialValue).toHaveBeenCalledWith( + expect.objectContaining({ + databaseType: expect.objectContaining({ value: 'mongodb' }), + kubernetesCluster: expect.objectContaining({ + value: 'Cluster 1', + }), + name: expect.stringContaining('mongodb-'), + }) + ); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.tsx new file mode 100644 index 0000000000000..23e8587d317fe --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.tsx @@ -0,0 +1,69 @@ +/* eslint-disable react/display-name */ +import React, { FC, useMemo } from 'react'; +import { Modal } from '@percona/platform-core'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { useStyles } from '@grafana/ui'; +import { StepProgress } from 'app/percona/dbaas/components/StepProgress/StepProgress'; +import { AddDBClusterModalProps, AddDBClusterFields } from './AddDBClusterModal.types'; +import { DBClusterBasicOptions } from './DBClusterBasicOptions/DBClusterBasicOptions'; +import { DBClusterAdvancedOptions } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions'; +import { getStyles } from './AddDBClusterModal.styles'; +import { FormRenderProps } from 'react-final-form'; +import { PMMServerUrlWarning } from '../../PMMServerURLWarning/PMMServerUrlWarning'; +import { useSelector } from 'react-redux'; +import { getAddDbCluster } from 'app/percona/shared/core/selectors'; +import { useShowPMMAddressWarning } from 'app/percona/shared/components/hooks/showPMMAddressWarning'; +import { getInitialValues, updateDatabaseClusterNameInitialValue } from './AddDBClusterModal.utils'; + +export const AddDBClusterModal: FC = ({ kubernetes, isVisible, setVisible, onSubmit }) => { + const styles = useStyles(getStyles); + const { loading } = useSelector(getAddDbCluster); + const [showPMMAddressWarning] = useShowPMMAddressWarning(); + + const initialValues = useMemo(() => getInitialValues(kubernetes), [kubernetes]); + const updatedItialValues = useMemo( + () => (isVisible ? updateDatabaseClusterNameInitialValue(initialValues) : initialValues), + [initialValues, isVisible] + ); + + const steps = useMemo( + () => [ + { + title: Messages.dbcluster.addModal.steps.basicOptions, + fields: [AddDBClusterFields.name, AddDBClusterFields.kubernetesCluster, AddDBClusterFields.databaseType], + render: ({ form }: FormRenderProps) => , + dataTestId: 'dbcluster-basic-options-step', + }, + { + title: Messages.dbcluster.addModal.steps.advancedOptions, + fields: [ + AddDBClusterFields.topology, + AddDBClusterFields.nodes, + AddDBClusterFields.memory, + AddDBClusterFields.cpu, + AddDBClusterFields.disk, + ], + render: (renderProps) => , + dataTestId: 'dbcluster-advanced-options-step', + }, + ], + [kubernetes] + ); + + return ( +
    + setVisible(false)}> +
    + {showPMMAddressWarning && } + onSubmit(values, showPMMAddressWarning)} + loading={loading} + /> +
    +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.types.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.types.ts new file mode 100644 index 0000000000000..920e5b1961efb --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.types.ts @@ -0,0 +1,38 @@ +import { Kubernetes } from '../../Kubernetes/Kubernetes.types'; +import { DBClusterResources, DBClusterTopology } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions.types'; +import { DatabaseOptionInitial, KubernetesOption } from './DBClusterBasicOptions/DBClusterBasicOptions.types'; + +export interface AddDBClusterModalProps { + kubernetes: Kubernetes[]; + isVisible: boolean; + setVisible: (value: boolean) => void; + onSubmit: (values: Record, showPMMAddressWarning: boolean) => void; +} + +export enum AddDBClusterFields { + name = 'name', + kubernetesCluster = 'kubernetesCluster', + databaseType = 'databaseType', + databaseVersion = 'databaseVersion', + topology = 'topology', + nodes = 'nodes', + single = 'single', + resources = 'resources', + memory = 'memory', + cpu = 'cpu', + disk = 'disk', + expose = 'expose', +} + +export interface AddDbClusterFormValues { + [AddDBClusterFields.topology]: DBClusterTopology; + [AddDBClusterFields.nodes]: number; + [AddDBClusterFields.single]: number; + [AddDBClusterFields.resources]: DBClusterResources; + [AddDBClusterFields.memory]: number; + [AddDBClusterFields.cpu]: number; + [AddDBClusterFields.disk]: number; + [AddDBClusterFields.databaseType]: DatabaseOptionInitial | undefined; + [AddDBClusterFields.kubernetesCluster]?: KubernetesOption; + [AddDBClusterFields.name]?: string; +} diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.utils.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.utils.ts new file mode 100644 index 0000000000000..52a3142b12417 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/AddDBClusterModal.utils.ts @@ -0,0 +1,47 @@ +import { getActiveOperators, getDatabaseOptionFromOperator } from '../../Kubernetes/Kubernetes.utils'; +import { AddDBClusterFields, AddDbClusterFormValues } from './AddDBClusterModal.types'; +import { INITIAL_VALUES } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants'; +import { getKubernetesOptions } from './DBClusterBasicOptions/DBClusterBasicOptions.utils'; +import { Kubernetes } from '../../Kubernetes/Kubernetes.types'; + +export const getInitialValues = (kubernetes: Kubernetes[]): AddDbClusterFormValues => { + const activeOperators = getActiveOperators(kubernetes); + + const initialValues: AddDbClusterFormValues = { + ...INITIAL_VALUES, + [AddDBClusterFields.databaseType]: + activeOperators.length === 1 + ? getDatabaseOptionFromOperator(activeOperators[0]) + : { value: undefined, label: undefined }, + }; + + if (kubernetes.length > 0) { + const kubernetesOptions = getKubernetesOptions(kubernetes); + const initialCluster = kubernetesOptions.length > 0 && kubernetesOptions[0]; + if (initialCluster) { + initialValues[AddDBClusterFields.kubernetesCluster] = initialCluster; + if (activeOperators.length > 0) { + const databaseDefaultOperator = getDatabaseOptionFromOperator(activeOperators[0]); + initialValues[AddDBClusterFields.databaseType] = databaseDefaultOperator; + initialValues[AddDBClusterFields.name] = `${databaseDefaultOperator?.value}-${generateUID()}`; + } + } + } + return initialValues; +}; + +export const generateUID = (): string => { + const firstPart = ('000' + ((Math.random() * 46656) | 0).toString(36)).slice(-3); + const secondPart = ('000' + ((Math.random() * 46656) | 0).toString(36)).slice(-3); + return firstPart + secondPart; +}; + +export const updateDatabaseClusterNameInitialValue = (initialValues: AddDbClusterFormValues) => { + if (initialValues[AddDBClusterFields.databaseType]) { + return { + ...initialValues, + [AddDBClusterFields.name]: `${initialValues[AddDBClusterFields.databaseType]?.value}-${generateUID()}`, + }; + } + return initialValues; +}; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants.ts new file mode 100644 index 0000000000000..7f7b131376f83 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants.ts @@ -0,0 +1,49 @@ +import { SelectableValue } from '@grafana/data'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DBClusterTopology, DBClusterResources, DBClusterDefaultResources } from './DBClusterAdvancedOptions.types'; + +export const TOPOLOGY_OPTIONS: SelectableValue[] = [ + { value: DBClusterTopology.cluster, label: Messages.dbcluster.addModal.topology.cluster }, + { value: DBClusterTopology.single, label: Messages.dbcluster.addModal.topology.single }, +]; + +export const RESOURCES_OPTIONS: SelectableValue[] = [ + { value: DBClusterResources.small, label: Messages.dbcluster.addModal.resources.small }, + { value: DBClusterResources.medium, label: Messages.dbcluster.addModal.resources.medium }, + { value: DBClusterResources.large, label: Messages.dbcluster.addModal.resources.large }, + { value: DBClusterResources.custom, label: Messages.dbcluster.addModal.resources.custom }, +]; + +export const DEFAULT_SIZES: DBClusterDefaultResources = { + small: { + memory: 2, + cpu: 1, + disk: 25, + }, + medium: { + memory: 8, + cpu: 4, + disk: 100, + }, + large: { + memory: 32, + cpu: 8, + disk: 500, + }, +}; + +export const INITIAL_VALUES = { + topology: DBClusterTopology.cluster, + nodes: 3, + single: 1, + resources: DBClusterResources.small, + memory: DEFAULT_SIZES.small.memory, + cpu: DEFAULT_SIZES.small.cpu, + disk: DEFAULT_SIZES.small.disk, +}; + +export const MIN_NODES = 3; +export const MIN_RESOURCES = 0.1; +export const MIN_DISK_SIZE = 1; +export const RECHECK_INTERVAL = 10000; +export const EXPECTED_DELAY = 250; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.styles.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.styles.ts new file mode 100644 index 0000000000000..30be95d3d0a53 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.styles.ts @@ -0,0 +1,64 @@ +import { stylesFactory } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = stylesFactory((theme: GrafanaTheme) => { + const { spacing, colors } = theme; + + return { + resourcesWrapper: css` + display: flex; + `, + resourcesInputCol: css` + display: flex; + flex-direction: column; + div { + width: 100px; + } + div:not(:last-child) { + margin-bottom: ${spacing.xl}; + } + `, + resourcesBarCol: css` + display: flex; + flex-direction: column; + margin-left: ${spacing.lg}; + width: 50%; + `, + nodesWrapper: css` + margin-bottom: ${spacing.md}; + width: 60px; + div, + label { + white-space: nowrap; + } + `, + resourcesBar: css` + margin-top: ${spacing.lg}; + margin-bottom: ${spacing.xl}; + `, + resourcesBarEmpty: css` + margin-top: ${spacing.lg}; + margin-bottom: 78px; + `, + resourcesBarLast: css` + margin-top: ${spacing.lg}; + `, + resourcesInfoWrapper: css` + display: flex; + align-items: center; + background-color: ${colors.bg2}; + padding: ${spacing.sm}; + width: fit-content; + margin-bottom: ${spacing.md}; + `, + resourcesInfoIcon: css` + margin-right: ${spacing.sm}; + `, + resourcesRadioWrapper: css` + align-items: center; + display: flex; + justify-content: space-between; + `, + }; +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.test.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.test.tsx new file mode 100644 index 0000000000000..2826625513b31 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { Form, FormRenderProps } from 'react-final-form'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Databases } from 'app/percona/shared/core'; +import { DBClusterAdvancedOptions } from './DBClusterAdvancedOptions'; +import { AddDBClusterFields } from '../AddDBClusterModal.types'; +import { DBClusterResources, DBClusterTopology } from './DBClusterAdvancedOptions.types'; + +jest.mock('../../DBCluster.service'); +jest.mock('../../PSMDB.service'); +jest.mock('../../XtraDB.service'); + +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); + +describe('DBClusterAdvancedOptions::', () => { + it('renders correctly', async () => { + act(() => { + render(
    } />); + }); + + const radioState = await screen.findByTestId('topology-radio-state'); + + expect(radioState).toBeInTheDocument(); + + expect(screen.getByTestId('nodes-number-input')).toBeInTheDocument(); + expect(screen.getAllByTestId('resources-radio-button').length).toBeGreaterThan(0); + expect(screen.getByTestId('memory-number-input')).toBeInTheDocument(); + expect(screen.getByTestId('cpu-number-input')).toBeInTheDocument(); + expect(screen.getByTestId('disk-number-input')).toBeInTheDocument(); + expect(screen.getByTestId('dbcluster-resources-bar-memory')).toBeInTheDocument(); + expect(screen.getByTestId('dbcluster-resources-bar-cpu')).toBeInTheDocument(); + expect(screen.getByTestId('disk-number-input')).toBeInTheDocument(); + }); + + it('renders correctly with initial values', async () => { + act(() => { + render( + } + /> + ); + }); + + const nodes = await screen.findByTestId('nodes-number-input'); + const topology = screen.getByTestId('topology-radio-state'); + + expect(nodes.getAttribute('value')).toBe('3'); + expect(topology.getAttribute('value')).toEqual(DBClusterTopology.cluster); + }); + + it('should set nodes to 1 when topology is single', async () => { + act(() => { + render( } />); + }); + + const topology = screen.getByTestId('topology-radio-state'); + + fireEvent.change(topology, { target: { value: DBClusterTopology.single } }); + + expect(screen.getByTestId('single-number-input')).toBeInTheDocument(); + }); + + it('should disable memory, cpu and disk when resources are not custom', async () => { + act(() => { + render( + } + /> + ); + }); + const memory = await screen.findByTestId('memory-number-input'); + const cpu = screen.getByTestId('cpu-number-input'); + const disk = screen.getByTestId('disk-number-input'); + + expect(memory).toBeDisabled(); + expect(cpu).toBeDisabled(); + expect(disk).toBeDisabled(); + }); + + it('should enable memory and cpu when resources is custom', async () => { + act(() => { + render( + } + /> + ); + }); + + const resources = await screen.findByTestId('resources-radio-state'); + fireEvent.change(resources, { target: { value: DBClusterResources.custom } }); + + const memory = screen.getByTestId('memory-number-input'); + const cpu = screen.getByTestId('cpu-number-input'); + const disk = screen.getByTestId('disk-number-input'); + + expect(memory).not.toBeDisabled(); + expect(cpu).not.toBeDisabled(); + expect(disk).not.toBeDisabled(); + }); + + it('should disabled single node topology when database is MongoDB', async () => { + act(() => { + render( + } + /> + ); + }); + + const topology = await screen.findAllByTestId('topology-radio-button'); + + expect(topology[1]).toBeDisabled(); + }); + + it('should enable single node topology when database is MySQL', async () => { + act(() => { + render( + } + /> + ); + }); + const topology = await screen.findAllByTestId('topology-radio-button'); + + expect(topology[1]).not.toBeDisabled(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.tsx new file mode 100644 index 0000000000000..9e7b7a360e824 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.tsx @@ -0,0 +1,267 @@ +import React, { FC, useCallback, useState, useMemo, useEffect } from 'react'; +import { FormRenderProps } from 'react-final-form'; +import { cx } from '@emotion/css'; +import { Icon, useStyles } from '@grafana/ui'; +import { NumberInputField, RadioButtonGroupField, logger } from '@percona/platform-core'; +import validators from 'app/percona/shared/helpers/validators'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { Databases } from 'app/percona/shared/core'; +import { Overlay } from 'app/percona/shared/components/Elements/Overlay/Overlay'; +import { + TOPOLOGY_OPTIONS, + RESOURCES_OPTIONS, + DEFAULT_SIZES, + MIN_NODES, + MIN_RESOURCES, + MIN_DISK_SIZE, + RECHECK_INTERVAL, + EXPECTED_DELAY, +} from './DBClusterAdvancedOptions.constants'; +import { getStyles } from './DBClusterAdvancedOptions.styles'; +import { AddDBClusterFields } from '../AddDBClusterModal.types'; +import { DBClusterTopology, DBClusterResources } from './DBClusterAdvancedOptions.types'; +import { canGetExpectedResources, resourceValidator } from './DBClusterAdvancedOptions.utils'; +import { ResourcesBar } from '../../ResourcesBar/ResourcesBar'; +import { CPU, Disk, Memory } from '../../../DBaaSIcons'; +import { DBClusterService } from '../../DBCluster.service'; +import { DBClusterAllocatedResources, DBClusterExpectedResources } from '../../DBCluster.types'; +import { newDBClusterService } from '../../DBCluster.utils'; +import { SwitchField } from '../../../Switch/Switch'; + +export const DBClusterAdvancedOptions: FC = ({ values, form }) => { + let allocatedTimer: NodeJS.Timeout; + let expectedTimer: NodeJS.Timeout; + const styles = useStyles(getStyles); + const [prevResources, setPrevResources] = useState(DBClusterResources.small); + const [customMemory, setCustomMemory] = useState(DEFAULT_SIZES.small.memory); + const [customCPU, setCustomCPU] = useState(DEFAULT_SIZES.small.cpu); + const [customDisk, setCustomDisk] = useState(DEFAULT_SIZES.small.disk); + const [allocatedResources, setAllocatedResources] = useState(); + const [loadingAllocatedResources, setLoadingAllocatedResources] = useState(false); + const [expectedResources, setExpectedResources] = useState(); + const [loadingExpectedResources, setLoadingExpectedResources] = useState(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + const mounted = { current: true }; + const { required, min } = validators; + const { change } = form; + const diskValidators = [required, min(MIN_DISK_SIZE)]; + const nodeValidators = [required, min(MIN_NODES)]; + const parameterValidators = [required, min(MIN_RESOURCES), resourceValidator]; + const { name, kubernetesCluster, topology, resources, memory, cpu, databaseType, disk, nodes, single } = values; + const resourcesBarStyles = useMemo( + () => ({ + [styles.resourcesBar]: !!allocatedResources, + [styles.resourcesBarEmpty]: !allocatedResources, + }), + [allocatedResources, styles.resourcesBar, styles.resourcesBarEmpty] + ); + const resourcesInputProps = { step: '0.1' }; + const parsePositiveInt = useCallback((value) => (value > 0 && Number.isInteger(+value) ? value : undefined), []); + + const topologies = useMemo( + () => + databaseType?.value !== Databases.mysql + ? [TOPOLOGY_OPTIONS[0], { ...TOPOLOGY_OPTIONS[1], disabled: true }] + : TOPOLOGY_OPTIONS, + [databaseType] + ); + + const getAllocatedResources = async (triggerLoading = true) => { + try { + if (allocatedTimer) { + clearTimeout(allocatedTimer); + } + + if (triggerLoading) { + setLoadingAllocatedResources(true); + } + const alloc = await DBClusterService.getAllocatedResources(kubernetesCluster.value); + setAllocatedResources(alloc); + } catch (e) { + logger.error(e); + } finally { + if (triggerLoading) { + setLoadingAllocatedResources(false); + } + + // don't schedule another request if the component was unmounted while the previous request was occuring + if (mounted.current) { + // eslint-disable-next-line react-hooks/exhaustive-deps + allocatedTimer = setTimeout(() => getAllocatedResources(false), RECHECK_INTERVAL); + } + } + }; + + useEffect(() => { + if (prevResources === DBClusterResources.custom) { + setCustomMemory(memory); + setCustomCPU(cpu); + setCustomDisk(disk); + } + + if (resources && resources !== DBClusterResources.custom) { + change(AddDBClusterFields.cpu, DEFAULT_SIZES[resources].cpu); + change(AddDBClusterFields.memory, DEFAULT_SIZES[resources].memory); + change(AddDBClusterFields.disk, DEFAULT_SIZES[resources].disk); + } else { + change(AddDBClusterFields.cpu, customCPU); + change(AddDBClusterFields.memory, customMemory); + change(AddDBClusterFields.disk, customDisk); + } + + setPrevResources(resources); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resources]); + + useEffect(() => { + if (kubernetesCluster) { + getAllocatedResources(); + } + + return () => { + mounted.current = false; + clearTimeout(allocatedTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kubernetesCluster]); + + useEffect(() => { + const getExpectedResources = async () => { + try { + const dbClusterService = newDBClusterService(databaseType.value); + setLoadingExpectedResources(true); + setExpectedResources( + await dbClusterService.getExpectedResources({ + clusterName: name, + kubernetesClusterName: kubernetesCluster, + databaseType: databaseType.value, + clusterSize: topology === DBClusterTopology.cluster ? nodes : single, + cpu, + memory, + disk, + }) + ); + } catch (e) { + logger.error(e); + } finally { + setLoadingExpectedResources(false); + } + }; + + if (canGetExpectedResources(kubernetesCluster, values)) { + if (expectedTimer) { + clearTimeout(expectedTimer); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + expectedTimer = setTimeout(() => getExpectedResources(), EXPECTED_DELAY); + } + + return () => clearTimeout(expectedTimer); + }, [memory, cpu, disk, kubernetesCluster, topology, nodes, single, databaseType]); + + useEffect(() => { + if (topology === DBClusterTopology.cluster && nodes < MIN_NODES) { + change(AddDBClusterFields.nodes, MIN_NODES); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [topology]); + + return ( + <> + +
    + {topology === DBClusterTopology.single ? ( + + ) : ( + + )} +
    + +
    + +
    + + {Messages.dbcluster.addModal.resourcesInfo} +
    +
    +
    +
    + + + +
    +
    + + } + total={allocatedResources?.total.cpu} + allocated={allocatedResources?.allocated.cpu} + expected={expectedResources?.expected.cpu} + className={cx(resourcesBarStyles)} + dataTestId="dbcluster-resources-bar-cpu" + /> + } + total={allocatedResources?.total.memory} + allocated={allocatedResources?.allocated.memory} + expected={expectedResources?.expected.memory} + className={cx(resourcesBarStyles)} + dataTestId="dbcluster-resources-bar-memory" + /> + } + total={allocatedResources?.total.disk} + allocated={allocatedResources?.allocated.disk} + expected={expectedResources?.expected.disk} + className={styles.resourcesBarLast} + dataTestId="dbcluster-resources-bar-disk" + /> + +
    +
    + + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types.ts new file mode 100644 index 0000000000000..b0ffbe6ac4ed1 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types.ts @@ -0,0 +1,19 @@ +export enum DBClusterTopology { + cluster = 'cluster', + single = 'single', +} + +export enum DBClusterResources { + small = 'small', + medium = 'medium', + large = 'large', + custom = 'custom', +} + +export interface DBClusterDefaultResources { + [key: string]: { + memory: number; + cpu: number; + disk: number; + }; +} diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils.test.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils.test.ts new file mode 100644 index 0000000000000..3cdd6ee65ea2b --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils.test.ts @@ -0,0 +1,59 @@ +import { DBClusterTopology } from './DBClusterAdvancedOptions.types'; +import { canGetExpectedResources, resourceValidator } from './DBClusterAdvancedOptions.utils'; + +describe('DBClusterAdvancedOptions.utils::', () => { + describe('resourceValidator::', () => { + it('returns undefined on undefined value', () => { + expect(resourceValidator(undefined)).toBeUndefined(); + }); + it('returns undefined when value is integer', () => { + expect(resourceValidator(10)).toBeUndefined(); + }); + it('returns undefined when has one decimal place', () => { + expect(resourceValidator(2.5)).toBeUndefined(); + }); + it("doesn't return undefined when value has more than one decimal place", () => { + expect(resourceValidator(3.74)).not.toBeUndefined(); + }); + }); + describe('canGetExpectedResources::', () => { + const kubernetesCluster = { + value: 'test', + label: 'test', + }; + const values = { + topology: DBClusterTopology.cluster, + memory: 2, + cpu: 4, + disk: 20, + nodes: 3, + }; + it('returns false when memory is undefined', () => { + expect(canGetExpectedResources(kubernetesCluster, { ...values, memory: undefined })).toBeFalsy(); + }); + it('returns false when cpu is less than zero', () => { + expect(canGetExpectedResources(kubernetesCluster, { ...values, cpu: -1 })).toBeFalsy(); + }); + it('returns false when topology is cluster but nodes is undefined or a string', () => { + expect(canGetExpectedResources(kubernetesCluster, { ...values, nodes: undefined })).toBeFalsy(); + expect(canGetExpectedResources(kubernetesCluster, { ...values, nodes: 'test_invalid_nodes' })).toBeFalsy(); + }); + it('returns true when topology is cluster and nodes is a positive number', () => { + expect(canGetExpectedResources(kubernetesCluster, { ...values, nodes: -3 })).toBeFalsy(); + expect(canGetExpectedResources(kubernetesCluster, { ...values, nodes: 2 })).toBeTruthy(); + expect(canGetExpectedResources(kubernetesCluster, { ...values, nodes: '7' })).toBeTruthy(); + }); + it('returns true when resources are valid and topology is not cluster', () => { + expect( + canGetExpectedResources(kubernetesCluster, { ...values, topology: DBClusterTopology.single }) + ).toBeTruthy(); + expect( + canGetExpectedResources(kubernetesCluster, { + ...values, + topology: DBClusterTopology.single, + nodes: 'test_invalid_nodes', + }) + ).toBeTruthy(); + }); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils.ts new file mode 100644 index 0000000000000..d3efb6a0d31f9 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils.ts @@ -0,0 +1,29 @@ +import { SelectableValue } from '@grafana/data'; +import { DBCluster } from '../../DBCluster.types'; +import { DBClusterTopology } from './DBClusterAdvancedOptions.types'; + +export const resourceValidator = (value?: number) => { + if (!value || Math.floor(value) === value) { + return undefined; + } + + const precision = value.toString().split('.')[1]?.length || 0; + + return precision > 1 ? 'Only one decimal place allowed' : undefined; +}; + +export const canGetExpectedResources = ( + kubernetesCluster: DBCluster | SelectableValue, + values: Record +) => { + const { topology, memory, cpu, disk, nodes } = values; + const clusterType = DBClusterTopology.cluster as string; + + return ( + kubernetesCluster && + memory > 0 && + cpu > 0 && + disk > 0 && + ((topology === clusterType && nodes && nodes > 0 && !isNaN(nodes)) || topology !== clusterType) + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.constants.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.constants.tsx new file mode 100644 index 0000000000000..8aa324102cc31 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.constants.tsx @@ -0,0 +1,11 @@ +import { DATABASE_LABELS, Databases } from 'app/percona/shared/core'; +import { Operators } from './DBClusterBasicOptions.types'; + +export const OPERATORS = [Operators.pxc, Operators.psmdb]; + +export const DatabaseOperators = { + [Operators.pxc]: DATABASE_LABELS[Databases.mysql], + [Operators.psmdb]: DATABASE_LABELS[Databases.mongodb], +}; + +export const CLUSTER_NAME_MAX_LENGTH = 20; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.hooks.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.hooks.ts new file mode 100644 index 0000000000000..af05b963f87d5 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.hooks.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { FormApi } from 'final-form'; +import { logger } from '@percona/platform-core'; +import { SelectableValue } from '@grafana/data'; +import { isOptionEmpty, newDBClusterService } from '../../DBCluster.utils'; +import { AddDBClusterFields } from '../AddDBClusterModal.types'; +import { findDefaultDatabaseVersion } from './DBClusterBasicOptions.utils'; + +export const useDatabaseVersions = ( + form: FormApi, + databaseType: SelectableValue, + kubernetesCluster: SelectableValue, + setLoadingDatabaseVersions: (loading: boolean) => void, + setDatabaseVersions: (versions: SelectableValue[]) => void +) => { + useEffect(() => { + const getDatabaseVersions = async () => { + try { + const dbClusterService = newDBClusterService(databaseType.value); + + setLoadingDatabaseVersions(true); + + const databaseVersions = await (await dbClusterService.getDatabaseVersions(kubernetesCluster.value)).filter( + ({ disabled }) => !disabled + ); + + setDatabaseVersions(databaseVersions); + form.change(AddDBClusterFields.databaseVersion, findDefaultDatabaseVersion(databaseVersions)); + } catch (e) { + logger.error(e); + } finally { + setLoadingDatabaseVersions(false); + } + }; + + if (!isOptionEmpty(databaseType) && !isOptionEmpty(kubernetesCluster)) { + getDatabaseVersions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [databaseType, kubernetesCluster]); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.test.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.test.tsx new file mode 100644 index 0000000000000..4750da0e1f10d --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Form, FormRenderProps } from 'react-final-form'; +import { DBClusterBasicOptions } from './DBClusterBasicOptions'; +import { AddDBClusterFields } from '../AddDBClusterModal.types'; +import { kubernetesClusterNameValidator } from './DBClusterBasicOptions.utils'; +import { Messages } from '../../../../DBaaS.messages'; +import { kubernetesStub } from '../../../Kubernetes/__mocks__/kubernetesStubs'; +import { render, fireEvent, screen } from '@testing-library/react'; + +describe('DBClusterBasicOptions::', () => { + it('renders correctly', () => { + render( + } + /> + ); + const databaseVersionField = screen.getByTestId('dbcluster-database-version-field'); + + expect(screen.getByTestId('name-text-input')).toBeInTheDocument(); + expect(screen.getByTestId('dbcluster-kubernetes-cluster-field')).toBeInTheDocument(); + expect(screen.getByTestId('dbcluster-database-type-field')).toBeInTheDocument(); + expect(databaseVersionField).toBeInTheDocument(); + expect(databaseVersionField.querySelector('input')).toBeDisabled(); + }); + + it('renders correctly with default values', () => { + render( + } + /> + ); + expect(screen.getByTestId('name-text-input')).toHaveValue('dbcluster'); + }); + + it('should validate cluster name correctly', () => { + const clusterName1 = '!!!!'; + const clusterName2 = '1bcd'; + const clusterName3 = 'abcd'; + + expect(kubernetesClusterNameValidator(clusterName1)).toEqual( + Messages.dbcluster.addModal.validationMessages.clusterName + ); + expect(kubernetesClusterNameValidator(clusterName2)).toEqual( + Messages.dbcluster.addModal.validationMessages.clusterName + ); + expect(kubernetesClusterNameValidator(clusterName3)).toEqual(undefined); + }); + + it('should validate cluster name length', () => { + render( + } + /> + ); + + const name = screen.getByTestId('name-text-input'); + fireEvent.change(name, { target: { value: 'testinvalidnamelength' } }); + + expect(screen.getByTestId('name-field-error-message').textContent?.length).toBeGreaterThan(0); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.tsx new file mode 100644 index 0000000000000..a1379d2bb01e7 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.tsx @@ -0,0 +1,122 @@ +import React, { FC, useCallback, useState, useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { TextInputField, validators } from '@percona/platform-core'; +import { Databases } from 'app/percona/shared/core'; +import { + SelectFieldAdapter, + AsyncSelectFieldAdapter, +} from 'app/percona/shared/components/Form/FieldAdapters/FieldAdapters'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DatabaseOption, DBClusterBasicOptionsProps, Operators } from './DBClusterBasicOptions.types'; +import { DATABASE_OPTIONS } from '../../DBCluster.constants'; +import { AddDBClusterFields } from '../AddDBClusterModal.types'; +import { DBClusterTopology } from '../DBClusterAdvancedOptions/DBClusterAdvancedOptions.types'; +import { getKubernetesOptions, kubernetesClusterNameValidator, optionRequired } from './DBClusterBasicOptions.utils'; +import { KubernetesOperatorStatus } from '../../../Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { SelectableValue } from '@grafana/data'; +import { isOptionEmpty } from '../../DBCluster.utils'; +import { useDatabaseVersions } from './DBClusterBasicOptions.hooks'; +import { CLUSTER_NAME_MAX_LENGTH } from './DBClusterBasicOptions.constants'; +import { getDatabaseOptionFromOperator } from '../../../Kubernetes/Kubernetes.utils'; +import { Kubernetes, Operator } from '../../../Kubernetes/Kubernetes.types'; + +const getAvailableDatabaseOptions = (kubernetesCluster: Kubernetes): DatabaseOption[] => { + const { operators } = kubernetesCluster; + const availableDatabaseOptions: DatabaseOption[] = []; + + Object.entries(operators).forEach(([operator, { status }]: [string, Operator]) => { + if (status === KubernetesOperatorStatus.ok) { + availableDatabaseOptions.push(getDatabaseOptionFromOperator(operator as Operators) as DatabaseOption); + } + }); + + return availableDatabaseOptions; +}; + +export const DBClusterBasicOptions: FC = ({ kubernetes, form }) => { + const { required, maxLength } = validators; + const { change } = form; + const { kubernetesCluster, databaseType } = form.getState().values; + const [databaseVersions, setDatabaseVersions] = useState([]); + const [loadingDatabaseVersions, setLoadingDatabaseVersions] = useState(false); + const onChangeDatabase = useCallback((databaseType) => { + if (databaseType.value !== Databases.mysql) { + change(AddDBClusterFields.topology, DBClusterTopology.cluster); + } + + change(AddDBClusterFields.databaseType, databaseType); + form.mutators.setClusterName(databaseType.value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const kubernetesOptions = getKubernetesOptions(kubernetes); + + const [databaseOptions, setDatabaseOptions] = useState(() => { + if (kubernetesCluster) { + const availableDatabaseOptions = getAvailableDatabaseOptions(kubernetesCluster); + return availableDatabaseOptions; + } + return DATABASE_OPTIONS; + }); + + const onChangeCluster = useCallback((selectedKubernetes) => { + const availableDatabaseOptions = getAvailableDatabaseOptions(selectedKubernetes); + + if (availableDatabaseOptions.length === 1) { + change(AddDBClusterFields.databaseType, availableDatabaseOptions[0]); + } else { + change(AddDBClusterFields.databaseType, { + value: undefined, + label: undefined, + }); + } + + setDatabaseOptions(availableDatabaseOptions); + change(AddDBClusterFields.kubernetesCluster, selectedKubernetes); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isDatabaseVersionDisabled = useMemo(() => isOptionEmpty(databaseType), [databaseType]); + + useDatabaseVersions(form, databaseType, kubernetesCluster, setLoadingDatabaseVersions, setDatabaseVersions); + + return ( + <> + + + + + + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types.ts new file mode 100644 index 0000000000000..fa7afe39b614e --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types.ts @@ -0,0 +1,36 @@ +import { FormApi } from 'final-form'; +import { Kubernetes, OperatorsList } from '../../../Kubernetes/Kubernetes.types'; +import { Databases } from 'app/percona/shared/core'; + +export interface DBClusterBasicOptionsProps { + kubernetes: Kubernetes[]; + form: FormApi; +} + +export enum Operators { + pxc = 'pxc', + psmdb = 'psmdb', +} + +export interface DatabaseOption { + value: Databases; + label: string; +} + +export interface DatabaseOptionInitial { + value?: Databases; + label?: string; +} + +export interface KubernetesOptionProps { + disabledOperators: Operators[]; + availableOperators: Operators[]; + kubernetesClusterName: string; +} + +export interface KubernetesOption { + value: string; + label: JSX.Element; + operators: OperatorsList; + availableOperators: Operators[]; +} diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.utils.test.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.utils.test.ts new file mode 100644 index 0000000000000..4a7bba3edbfbb --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.utils.test.ts @@ -0,0 +1,22 @@ +import { findDefaultDatabaseVersion } from './DBClusterBasicOptions.utils'; + +describe('DBClusterBasicOptions.utils::', () => { + it('finds default database version', () => { + const versions = [ + { + label: 'db1', + value: 'db1', + default: false, + disabled: false, + }, + { + label: 'db2', + value: 'db2', + default: true, + disabled: false, + }, + ]; + + expect(findDefaultDatabaseVersion(versions)).toEqual(versions[1]); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.utils.tsx b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.utils.tsx new file mode 100644 index 0000000000000..ac61e1181e89c --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.utils.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DatabaseOperators, OPERATORS } from './DBClusterBasicOptions.constants'; +import { KubernetesOperatorStatus } from '../../../Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { OptionContent } from '../../OptionContent/OptionContent'; +import { Kubernetes } from '../../../Kubernetes/Kubernetes.types'; +import { KubernetesOption as KubernetesOptionInterface, KubernetesOptionProps } from './DBClusterBasicOptions.types'; +import { DatabaseVersion } from '../../DBCluster.types'; + +export const kubernetesClusterNameValidator = (value: string) => { + const clusterNameRegexp = /^[a-z]([-a-z0-9]*[a-z0-9])?$/; + + return clusterNameRegexp.test(value) ? undefined : Messages.dbcluster.addModal.validationMessages.clusterName; +}; + +const KubernetesOption: FC = ({ + disabledOperators, + availableOperators, + kubernetesClusterName, +}) => ( + DatabaseOperators[databaseType])} + disabledTags={disabledOperators.map((databaseType) => DatabaseOperators[databaseType])} + dataTestId="kubernetes-option" + /> +); + +export const getKubernetesOptions = (kubernetes: Kubernetes[]): KubernetesOptionInterface[] => + kubernetes + .map((kubernetesCluster) => { + const { kubernetesClusterName, operators } = kubernetesCluster; + + const availableOperators = OPERATORS.filter( + (databaseType) => operators[databaseType].status === KubernetesOperatorStatus.ok + ); + const disabledOperators = OPERATORS.filter( + (databaseType) => operators[databaseType].status !== KubernetesOperatorStatus.ok + ); + + return { + value: kubernetesClusterName, + label: ( + + ), + operators, + availableOperators, + }; + }) + .filter((operators) => operators.availableOperators.length); + +export const optionRequired = (option: SelectableValue) => + option && option.label && option.value ? undefined : Messages.dbcluster.addModal.validationMessages.requiredField; + +export const findDefaultDatabaseVersion = (versions: DatabaseVersion[]) => versions.find((version) => version.default); diff --git a/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/__mocks__/addDBClusterModalStubs.ts b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/__mocks__/addDBClusterModalStubs.ts new file mode 100644 index 0000000000000..421cf713ddd6a --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/AddDBClusterModal/__mocks__/addDBClusterModalStubs.ts @@ -0,0 +1,13 @@ +export const kubernetesOptionsStub = [ + { + value: 'Test Cluster 1', + label: 'Test Cluster 1', + }, + { + value: 'Test Cluster 2', + label: 'Test Cluster 2', + }, +]; + +export const setVisibleStub = jest.fn(); +export const onDBClusterAddedStub = jest.fn(); diff --git a/public/app/percona/dbaas/components/DBCluster/ColumnRenderers/ColumnRenderers.tsx b/public/app/percona/dbaas/components/DBCluster/ColumnRenderers/ColumnRenderers.tsx new file mode 100644 index 0000000000000..48582c6f3ac80 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ColumnRenderers/ColumnRenderers.tsx @@ -0,0 +1,49 @@ +/* eslint-disable react/display-name */ +import React from 'react'; +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { DBClusterConnection } from '../DBClusterConnection/DBClusterConnection'; +import { DBClusterStatus } from '../DBClusterStatus/DBClusterStatus'; +import { DBCluster } from '../DBCluster.types'; +import { DBClusterParameters } from '../DBClusterParameters/DBClusterParameters'; +import { DBClusterName } from '../DBClusterName/DBClusterName'; +import { DBClusterActions } from '../DBClusterActions/DBClusterActions'; +import { DBClusterActionsProps } from '../DBClusterActions/DBClusterActions.types'; +import { DBClusterStatusProps } from './ColumnRenderers.types'; +import { formatDBClusterVersion } from '../DBCluster.utils'; + +export const clusterNameRender = (dbCluster: DBCluster) => ; + +export const databaseTypeRender = (dbCluster: DBCluster) => + `${DATABASE_LABELS[dbCluster.databaseType]} ${formatDBClusterVersion(dbCluster.installedImage)}`; + +export const clusterStatusRender = ({ setSelectedCluster, setLogsModalVisible }: DBClusterStatusProps) => ( + dbCluster: DBCluster +) => ( + +); + +export const connectionRender = (dbCluster: DBCluster) => ; +export const parametersRender = (dbCluster: DBCluster) => ; + +export const clusterActionsRender = ({ + setSelectedCluster, + setDeleteModalVisible, + setEditModalVisible, + setLogsModalVisible, + setUpdateModalVisible, + getDBClusters, +}: Omit) => (dbCluster: DBCluster) => ( + +); diff --git a/public/app/percona/dbaas/components/DBCluster/ColumnRenderers/ColumnRenderers.types.ts b/public/app/percona/dbaas/components/DBCluster/ColumnRenderers/ColumnRenderers.types.ts new file mode 100644 index 0000000000000..48793a92f5326 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ColumnRenderers/ColumnRenderers.types.ts @@ -0,0 +1,6 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface DBClusterStatusProps { + setSelectedCluster: (dbCluster: DBCluster) => void; + setLogsModalVisible: (isVisible: boolean) => void; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.constants.ts b/public/app/percona/dbaas/components/DBCluster/DBCluster.constants.ts new file mode 100644 index 0000000000000..bd0bc661140ab --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.constants.ts @@ -0,0 +1,34 @@ +import { Databases, DATABASE_LABELS } from 'app/percona/shared/core'; +import { Operators } from './AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { DatabaseOperatorsMap, DBClusterServiceDatabasesMap } from './DBCluster.types'; +import { PSMDBService } from './PSMDB.service'; +import { XtraDBService } from './XtraDB.service'; + +export const ADVANCED_SETTINGS_URL = '/graph/settings/advanced-settings'; + +export const DATABASE_OPTIONS = [ + { + value: Databases.mysql, + label: DATABASE_LABELS.mysql, + }, + { + value: Databases.mongodb, + label: DATABASE_LABELS.mongodb, + }, +]; + +export const SERVICE_MAP: Partial = { + [Databases.mysql]: new XtraDBService(), + [Databases.mongodb]: new PSMDBService(), +}; + +export const THOUSAND = 1000; +export const BILLION = 10 ** 9; +export const RESOURCES_PRECISION = 2; + +export const DATABASE_OPERATORS: Partial = { + [Operators.pxc]: Databases.mysql, + [Operators.psmdb]: Databases.mongodb, +}; + +export const GET_CLUSTERS_CANCEL_TOKEN = 'getClusters'; diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.service.ts b/public/app/percona/dbaas/components/DBCluster/DBCluster.service.ts new file mode 100644 index 0000000000000..d21ef1a62dd43 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.service.ts @@ -0,0 +1,94 @@ +import { Databases } from 'app/percona/shared/core'; +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { Kubernetes } from '../Kubernetes/Kubernetes.types'; +import { ManageComponentsVersionsRenderProps } from '../Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types'; +import { BILLION, RESOURCES_PRECISION, THOUSAND } from './DBCluster.constants'; +import { + DBCluster, + DBClusterPayload, + DBClusterConnectionAPI, + DBClusterLogsAPI, + DBClusterAllocatedResources, + DBClusterAllocatedResourcesAPI, + DatabaseVersion, + DBClusterComponents, + DBClusterExpectedResources, + ResourcesUnits, + CpuUnits, + DBClusterListResponse, +} from './DBCluster.types'; +import { formatResources } from './DBCluster.utils'; + +export abstract class DBClusterService { + abstract addDBCluster(dbCluster: DBCluster): Promise; + + abstract updateDBCluster(dbCluster: DBCluster): Promise; + + abstract suspendDBCluster(dbCluster: DBCluster): Promise; + + abstract resumeDBCluster(dbCluster: DBCluster): Promise; + + abstract deleteDBClusters(dbCluster: DBCluster): Promise; + + abstract getDBClusterCredentials(dbCluster: DBCluster): Promise; + + abstract restartDBCluster(dbCluster: DBCluster): Promise; + + abstract getComponents(kubernetesClusterName: string): Promise; + + abstract setComponents( + kubernetesClusterName: string, + componentsVersions: ManageComponentsVersionsRenderProps + ): Promise; + + abstract getDatabaseVersions(kubernetesClusterName: string): Promise; + + abstract getExpectedResources(dbCluster: DBCluster): Promise; + + abstract toModel(dbCluster: DBClusterPayload, kubernetesClusterName: string, databaseType: Databases): DBCluster; + + static async getDBClusters(kubernetes: Kubernetes, token?: CancelToken): Promise { + return apiManagement.post('/DBaaS/DBClusters/List', kubernetes, true, token); + } + + static async getLogs({ kubernetesClusterName, clusterName }: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/GetLogs', + { + kubernetes_cluster_name: kubernetesClusterName, + cluster_name: clusterName, + }, + true + ); + } + + static async getAllocatedResources(kubernetesClusterName: string): Promise { + return apiManagement + .post('/DBaaS/Kubernetes/Resources/Get', { + kubernetes_cluster_name: kubernetesClusterName, + }) + .then(({ all, available }) => { + const allocatedCpu = all.cpu_m - available.cpu_m; + const allocatedMemory = all.memory_bytes - available.memory_bytes; + const allocatedDisk = all.disk_size - available.disk_size; + + return { + total: { + cpu: { value: all.cpu_m / THOUSAND, units: CpuUnits.MILLI, original: +all.cpu_m }, + memory: { value: all.memory_bytes / BILLION, units: ResourcesUnits.GB, original: +all.memory_bytes }, + disk: formatResources(+all.disk_size, RESOURCES_PRECISION), + }, + allocated: { + cpu: { value: allocatedCpu / THOUSAND, units: CpuUnits.MILLI, original: allocatedCpu }, + memory: { + value: allocatedMemory / BILLION, + units: ResourcesUnits.GB, + original: allocatedMemory, + }, + disk: { value: allocatedDisk / BILLION, units: ResourcesUnits.GB, original: allocatedDisk }, + }, + }; + }); + } +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.service.utils.ts b/public/app/percona/dbaas/components/DBCluster/DBCluster.service.utils.ts new file mode 100644 index 0000000000000..fb9b7006848e3 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.service.utils.ts @@ -0,0 +1,31 @@ +import { SelectableValue } from '@grafana/data'; +import { + DEFAULT_SUFFIX, + VERSION_PREFIX, +} from '../Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.constants'; +import { + ManageComponentsVersionsRenderProps, + SupportedComponents, +} from '../Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types'; +import { Operators } from './AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { DBClusterChangeComponentAPI } from './DBCluster.types'; + +export const getComponentChange = ( + operator: Operators, + component: SupportedComponents, + componentsVersions: ManageComponentsVersionsRenderProps +): DBClusterChangeComponentAPI => { + const name = `${operator}${component}`; + const defaultName = `${name}${DEFAULT_SUFFIX}`; + const versions = componentsVersions[name] as SelectableValue[]; + const defaultVersion = componentsVersions[defaultName]; + + return { + default_version: defaultVersion.name.replace(VERSION_PREFIX, ''), + versions: versions.map(({ label, value }) => ({ + version: label as string, + ...(value && { enable: true }), + ...(!value && { disable: true }), + })), + }; +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBCluster.styles.ts new file mode 100644 index 0000000000000..afc7fc7a45b33 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.styles.ts @@ -0,0 +1,14 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme) => ({ + actionPanel: css` + display: flex; + justify-content: flex-end; + margin-bottom: ${theme.spacing.sm}; + `, + actionsColumn: css` + display: flex; + justify-content: center; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBCluster.test.tsx new file mode 100644 index 0000000000000..0382d8acb8074 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.test.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { DBCluster } from './DBCluster'; +import { formatDBClusterVersion } from './DBCluster.utils'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { DBClusterStatus } from './DBCluster.types'; + +jest.mock('app/core/app_events'); +jest.mock('app/percona/dbaas/components/Kubernetes/Kubernetes.service'); +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); + +describe('DBCluster::', () => { + it('renders correctly without clusters', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(await screen.getAllByTestId('dbcluster-add-cluster-button')).toHaveLength(2); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('renders correctly with clusters', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(await screen.getAllByTestId('dbcluster-add-cluster-button')).toHaveLength(1); + expect(screen.getAllByTestId('table-row')).toHaveLength(1); + }); + + it('renders correctly with failed status', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(await screen.getAllByTestId('cluster-progress-bar').length).toBeGreaterThan(0); + expect(await screen.getAllByTestId('cluster-status-error-message').length).toBeGreaterThan(0); + }); + + it('renders database types correctly', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(screen.getAllByRole('cell')[1].textContent).toEqual( + `${DATABASE_LABELS.mongodb} ${formatDBClusterVersion('percona/percona-xtra-dbcluster:8.0')}` + ); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.tsx b/public/app/percona/dbaas/components/DBCluster/DBCluster.tsx new file mode 100644 index 0000000000000..9c86494e290a3 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.tsx @@ -0,0 +1,250 @@ +import React, { FC, useCallback, useMemo, useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useStyles } from '@grafana/ui'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import Page from 'app/core/components/Page/Page'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { TechnicalPreview } from 'app/percona/shared/components/Elements/TechnicalPreview/TechnicalPreview'; +import { Table } from 'app/percona/shared/components/Elements/Table'; +import { getKubernetes, getPerconaDBClusters, getPerconaSettingFlag } from 'app/percona/shared/core/selectors'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { AddClusterButton } from '../AddClusterButton/AddClusterButton'; +import { getStyles } from './DBCluster.styles'; +import { DBCluster as Cluster } from './DBCluster.types'; +import { AddDBClusterModal } from './AddDBClusterModal/AddDBClusterModal'; +import { EditDBClusterModal } from './EditDBClusterModal/EditDBClusterModal'; +import { DBClusterLogsModal } from './DBClusterLogsModal/DBClusterLogsModal'; +import { + clusterStatusRender, + connectionRender, + databaseTypeRender, + parametersRender, + clusterNameRender, + clusterActionsRender, +} from './ColumnRenderers/ColumnRenderers'; +import { DeleteDBClusterModal } from './DeleteDBClusterModal/DeleteDBClusterModal'; +import { UpdateDBClusterModal } from './UpdateDBClusterModal/UpdateDBClusterModal'; +import { addDbClusterAction, fetchDBClustersAction, fetchKubernetesAction } from 'app/percona/shared/core/reducers'; +import { useCatchCancellationError } from 'app/percona/shared/components/hooks/catchCancellationError'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { CHECK_OPERATOR_UPDATE_CANCEL_TOKEN, GET_KUBERNETES_CANCEL_TOKEN } from '../Kubernetes/Kubernetes.constants'; +import { RECHECK_INTERVAL } from './AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants'; +import { CancelToken } from 'axios'; +import { isKubernetesListUnavailable } from '../Kubernetes/Kubernetes.utils'; +import { GET_CLUSTERS_CANCEL_TOKEN } from './DBCluster.constants'; +import { useAppDispatch } from 'app/store/store'; +import { logger } from '@sentry/utils'; + +export const DBCluster: FC = () => { + const styles = useStyles(getStyles); + const [addModalVisible, setAddModalVisible] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); + const [logsModalVisible, setLogsModalVisible] = useState(false); + const [updateModalVisible, setUpdateModalVisible] = useState(false); + const [selectedCluster, setSelectedCluster] = useState(); + const navModel = usePerconaNavModel('dbclusters'); + const dispatch = useAppDispatch(); + const [generateToken] = useCancelToken(); + const { result: kubernetes = [], loading: kubernetesLoading } = useSelector(getKubernetes); + const { result: dbClusters = [], loading: dbClustersLoading } = useSelector(getPerconaDBClusters); + const [catchFromAsyncThunkAction] = useCatchCancellationError(); + const [loading, setLoading] = useState(kubernetesLoading); + const addDisabled = kubernetes.length === 0 || isKubernetesListUnavailable(kubernetes) || loading; + + const getDBClusters = useCallback( + async (triggerLoading = true) => { + if (!kubernetes.length) { + return; + } + + if (triggerLoading) { + setLoading(true); + } + + const tokens: CancelToken[] = kubernetes.map((k) => + generateToken(`${GET_CLUSTERS_CANCEL_TOKEN}-${k.kubernetesClusterName}`) + ); + + const result = await catchFromAsyncThunkAction(dispatch(fetchDBClustersAction({ kubernetes, tokens }))); + + // undefined means request was cancelled + if (result === undefined) { + return; + } + + setLoading(false); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [kubernetes] + ); + + const columns = useMemo( + () => [ + { + Header: Messages.dbcluster.table.nameColumn, + accessor: clusterNameRender, + }, + { + Header: Messages.dbcluster.table.databaseTypeColumn, + accessor: databaseTypeRender, + }, + { + Header: Messages.dbcluster.table.connectionColumn, + accessor: connectionRender, + }, + { + Header: Messages.dbcluster.table.clusterParametersColumn, + accessor: parametersRender, + }, + { + Header: Messages.dbcluster.table.clusterStatusColumn, + accessor: clusterStatusRender({ + setSelectedCluster, + setLogsModalVisible, + }), + }, + { + Header: Messages.dbcluster.table.actionsColumn, + accessor: clusterActionsRender({ + setSelectedCluster, + setDeleteModalVisible, + setEditModalVisible, + setLogsModalVisible, + setUpdateModalVisible, + getDBClusters, + }), + }, + ], + [setSelectedCluster, setDeleteModalVisible, getDBClusters] + ); + + const AddNewClusterButton = useCallback( + () => ( + setAddModalVisible(!addModalVisible)} + data-testid="dbcluster-add-cluster-button" + /> + ), + [addModalVisible, addDisabled] + ); + + const addCluster = async (values: Record, showPMMAddressWarning: boolean) => { + try { + await dispatch(addDbClusterAction({ values, setPMMAddress: showPMMAddressWarning })).unwrap(); + setAddModalVisible(false); + getDBClusters(true); + } catch (e) { + logger.error(e); + } + }; + + const getRowKey = useCallback(({ original }) => `${original.kubernetesClusterName}${original.clusterName}`, []); + + useEffect(() => { + if (!deleteModalVisible && !editModalVisible && !logsModalVisible && !updateModalVisible) { + setSelectedCluster(undefined); + } + }, [deleteModalVisible, editModalVisible, logsModalVisible, updateModalVisible]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('dbaasEnabled'), []); + + useEffect(() => { + dispatch( + fetchKubernetesAction({ + kubernetes: generateToken(GET_KUBERNETES_CANCEL_TOKEN), + operator: generateToken(CHECK_OPERATOR_UPDATE_CANCEL_TOKEN), + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + getDBClusters(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kubernetes]); + + useEffect(() => { + let timeout: NodeJS.Timeout; + if (!dbClustersLoading) { + timeout = setTimeout(() => getDBClusters(false), RECHECK_INTERVAL); + } + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbClustersLoading]); + + useEffect( + () => + setLoading((prevLoading) => { + if (!kubernetesLoading && !kubernetes.length) { + return false; + } + return prevLoading || kubernetesLoading; + }), + [kubernetes.length, kubernetesLoading] + ); + + return ( + + + + +
    +
    + +
    + + + {selectedCluster && ( + + )} + {logsModalVisible && ( + + )} + {selectedCluster && updateModalVisible && ( + + )} +
    } + rowKey={getRowKey} + /> + + + + + ); +}; + +export default DBCluster; diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.types.ts b/public/app/percona/dbaas/components/DBCluster/DBCluster.types.ts new file mode 100644 index 0000000000000..e85afdd7bced4 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.types.ts @@ -0,0 +1,278 @@ +import { SelectableValue } from '@grafana/data'; +import { Databases } from 'app/percona/shared/core'; +import { Operators } from './AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { DBClusterService } from './DBCluster.service'; + +export type AddDBClusterAction = (dbCluster: DBCluster) => void; +export type GetDBClustersAction = () => void; +export type SetDBClustersLoadingAction = (loading: boolean) => void; +export type ManageDBClusters = [DBCluster[], GetDBClustersAction, SetDBClustersLoadingAction, boolean]; + +export enum DBClusterType { + pxc = 'DB_CLUSTER_TYPE_PXC', + psmdb = 'DB_CLUSTER_TYPE_PSMDB', +} + +export interface DBCluster { + clusterName: string; + kubernetesClusterName: string; + databaseType: Databases; + clusterSize: number; + memory: number; + cpu: number; + disk: number; + status?: DBClusterStatus; + message?: string; + suspend?: boolean; + resume?: boolean; + finishedSteps?: number; + totalSteps?: number; + databaseImage?: string; + expose?: boolean; + installedImage?: string; + availableImage?: string; +} + +export enum DBClusterStatus { + invalid = 'DB_CLUSTER_STATE_INVALID', + changing = 'DB_CLUSTER_STATE_CHANGING', + ready = 'DB_CLUSTER_STATE_READY', + failed = 'DB_CLUSTER_STATE_FAILED', + deleting = 'DB_CLUSTER_STATE_DELETING', + suspended = 'DB_CLUSTER_STATE_PAUSED', + upgrading = 'DB_CLUSTER_STATE_UPGRADING', + unknown = 'DB_CLUSTER_STATE_UNKNOWN', +} + +export type DBClusterStatusMap = { + [key in DBClusterStatus]: string; +}; + +export type DBClusterServiceDatabasesMap = { + [key in Databases]: DBClusterService; +}; + +export type OperatorDatabasesMap = { + [key in Databases]: Operators; +}; + +export type DatabaseOperatorsMap = { + [key in Operators]: Databases; +}; + +export type ActiveOperatorsMap = { + [key in Operators]?: boolean; +}; + +export interface DBClusterConnection { + host: string; + password: string; + port: number; + username: string; +} + +export interface DBClusterLogs { + pods: DBClusterPodLogs[]; +} + +export interface DBClusterPodLogs { + name: string; + isOpen: boolean; + events: string; + containers: DBClusterContainerLogs[]; +} + +export interface DBClusterContainerLogs { + name: string; + isOpen: boolean; + logs: string; +} + +export interface DBClusterAllocatedResources { + total: DBClusterResources; + allocated: DBClusterResources; +} + +export interface DBClusterExpectedResources { + expected: DBClusterResources; +} + +interface DBClusterResources { + cpu: ResourcesWithUnits; + disk: ResourcesWithUnits; + memory: ResourcesWithUnits; +} + +export interface ResourcesWithUnits { + value: number; + units: ResourcesUnits | CpuUnits; + original: number; +} + +export enum ResourcesUnits { + BYTES = 'Bytes', + KB = 'KB', + MB = 'MB', + GB = 'GB', + TB = 'TB', + PB = 'PB', + EB = 'EB', +} + +export enum CpuUnits { + MILLI = 'CPU', +} + +export interface DatabaseVersion extends SelectableValue { + default: boolean; + disabled: boolean; +} + +export interface DBClusterPayload { + kubernetes_cluster_name: string; + name: string; + state?: DBClusterStatus; + operation?: DBClusterOperationAPI; + params: DBClusterParamsAPI; + suspend?: boolean; + resume?: boolean; + expose?: boolean; + exposed?: boolean; + installed_image?: string; + available_image?: string; + image?: string; +} + +export interface DBClusterActionAPI { + kubernetes_cluster_name: string; + name: string; + cluster_type: DBClusterType; +} + +interface DBClusterParamsAPI { + cluster_size: number; + pxc?: DBClusterContainerAPI; + haproxy?: Omit; + replicaset?: DBClusterContainerAPI; + image?: string; +} + +interface DBClusterContainerAPI { + compute_resources: DBClusterComputeResourcesAPI; + disk_size: number; + image?: string; +} + +interface DBClusterComputeResourcesAPI { + cpu_m: number; + memory_bytes: number; +} + +interface DBClusterOperationAPI { + message: string; + finished_steps: number; + total_steps: number; +} + +export interface DBClusterConnectionAPI { + connection_credentials: DBClusterConnection; +} + +export interface DBClusterLogsAPI { + logs: DBClusterLogAPI[]; +} + +export interface DBClusterLogAPI { + pod: string; + container?: string; + logs: string[]; +} + +export interface DBClusterAllocatedResourcesAPI { + all: ResourcesAPI; + available: ResourcesAPI; +} + +export interface DBClusterExpectedResourcesAPI { + expected: ResourcesAPI; +} + +interface ResourcesAPI { + cpu_m: number; + disk_size: number; + memory_bytes: number; +} + +export interface DBClusterComponents { + versions: DBClusterVersion[]; +} + +export interface DBClusterVersion { + product: string; + operator: string; + matrix: DBClusterMatrix; +} + +export interface DBClusterMatrix { + mongod?: DBClusterComponent; + pxc?: DBClusterComponent; + pmm?: DBClusterComponent; + proxysql?: DBClusterComponent; + haproxy?: DBClusterComponent; + backup?: DBClusterComponent; + operator?: DBClusterComponent; + log_collector?: DBClusterComponent; +} + +export interface DBClusterComponent { + [key: string]: { + image_path: string; + image_hash: string; + status: string; + critical: boolean; + default?: boolean; + disabled?: boolean; + }; +} + +export enum DBClusterComponentVersionStatus { + available = 'available', + recommended = 'recommended', +} + +export interface DBClusterChangeComponentsAPI { + kubernetes_cluster_name: string; + pxc?: DBClusterChangeComponentAPI; + haproxy?: DBClusterChangeComponentAPI; + mongod?: DBClusterChangeComponentAPI; +} + +export interface DBClusterChangeComponentAPI { + default_version?: string; + versions: DBClusterChangeComponentVersionAPI[]; +} + +export interface DBClusterChangeComponentVersionAPI { + version: string; + disable?: boolean; + enable?: boolean; +} + +export interface DBClusterListResponse { + pxc_clusters: DBClusterPayload[]; + psmdb_clusters: DBClusterPayload[]; +} + +export interface DBClusterSuspendResumeRequest { + kubernetes_cluster_name: string; + name: string; + params: DBClusterSuspendParamsRequest | DBClusterResumeParamsRequest; +} + +export interface DBClusterSuspendParamsRequest { + suspend: boolean; +} + +export interface DBClusterResumeParamsRequest { + resume: boolean; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.utils.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBCluster.utils.test.tsx new file mode 100644 index 0000000000000..5f547e2b4ff03 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.utils.test.tsx @@ -0,0 +1,164 @@ +import { + isClusterChanging, + formatResources, + isOptionEmpty, + getResourcesDifference, + getExpectedResourcesDifference, + getResourcesSum, + formatDBClusterVersion, + formatDBClusterVersionWithBuild, +} from './DBCluster.utils'; +import { dbClustersStub, resourcesA, resourcesB, resourcesC } from './__mocks__/dbClustersStubs'; +import { DBClusterExpectedResources, DBClusterStatus, ResourcesUnits, ResourcesWithUnits } from './DBCluster.types'; + +describe('DBCluster.utils::', () => { + it('returns true if cluster is changing', () => { + const result = isClusterChanging({ + ...dbClustersStub[0], + status: DBClusterStatus.changing, + }); + + expect(result).toBeTruthy(); + }); + it('returns true if cluster is deleting', () => { + const result = isClusterChanging({ + ...dbClustersStub[0], + status: DBClusterStatus.deleting, + }); + + expect(result).toBeTruthy(); + }); + it('returns false if cluster is ready', () => { + const result = isClusterChanging({ + ...dbClustersStub[0], + status: DBClusterStatus.ready, + }); + + expect(result).toBeFalsy(); + }); + it('returns false if cluster is invalid', () => { + const result = isClusterChanging({ + ...dbClustersStub[0], + status: DBClusterStatus.invalid, + }); + + expect(result).toBeFalsy(); + }); + it('returns false if cluster has no status', () => { + const result = isClusterChanging({ + ...dbClustersStub[0], + }); + + expect(result).toBeFalsy(); + }); + it('formats resources correctly', () => { + expect(formatResources(1000, 2)).toEqual({ value: 1, units: ResourcesUnits.KB, original: 1000 }); + expect(formatResources(1010, 2)).toEqual({ value: 1.01, units: ResourcesUnits.KB, original: 1010 }); + expect(formatResources(1010, 1)).toEqual({ value: 1, units: ResourcesUnits.KB, original: 1010 }); + expect(formatResources(1010, 1)).toEqual({ value: 1, units: ResourcesUnits.KB, original: 1010 }); + expect(formatResources(1015, 3)).toEqual({ value: 1.015, units: ResourcesUnits.KB, original: 1015 }); + expect(formatResources(2597, 3)).toEqual({ value: 2.597, units: ResourcesUnits.KB, original: 2597 }); + expect(formatResources(1500000000, 2)).toEqual({ value: 1.5, units: ResourcesUnits.GB, original: 1500000000 }); + expect(formatResources(1570000000, 3)).toEqual({ value: 1.57, units: ResourcesUnits.GB, original: 1570000000 }); + expect(formatResources(6200000000000, 2)).toEqual({ + value: 6.2, + units: ResourcesUnits.TB, + original: 6200000000000, + }); + expect(formatResources(6244440000000, 5)).toEqual({ + value: 6.24444, + units: ResourcesUnits.TB, + original: 6244440000000, + }); + }); + it('indentifies empty option correctly', () => { + expect(isOptionEmpty(undefined)).toBeTruthy(); + expect(isOptionEmpty({})).toBeTruthy(); + expect(isOptionEmpty({ label: 'test label' })).toBeTruthy(); + expect(isOptionEmpty({ value: 'test value' })).toBeFalsy(); + }); + it('calculates resources difference correctly', () => { + const expectedResourcesA: DBClusterExpectedResources = { + expected: { + cpu: resourcesA, + memory: resourcesA, + disk: resourcesA, + }, + }; + const expectedResourcesB: DBClusterExpectedResources = { + expected: { + cpu: resourcesB, + memory: resourcesB, + disk: resourcesB, + }, + }; + const resultA: ResourcesWithUnits = { + value: 0, + original: 0, + units: ResourcesUnits.BYTES, + }; + const resultB: ResourcesWithUnits = { + value: -10, + original: -10, + units: ResourcesUnits.BYTES, + }; + const resultC: ResourcesWithUnits = { + value: 10, + original: 10, + units: ResourcesUnits.BYTES, + }; + + expect(getResourcesDifference(resourcesA, resourcesA)).toEqual(resultA); + expect(getResourcesDifference(resourcesA, resourcesB)).toEqual(resultB); + expect(getResourcesDifference(resourcesB, resourcesA)).toEqual(resultC); + expect(getResourcesDifference(resourcesB, resourcesC)).toBeNull(); + expect(getExpectedResourcesDifference(expectedResourcesA, expectedResourcesA)).toEqual({ + expected: { + cpu: resultA, + memory: resultA, + disk: resultA, + }, + }); + expect(getExpectedResourcesDifference(expectedResourcesA, expectedResourcesB)).toEqual({ + expected: { + cpu: resultB, + memory: resultB, + disk: resultB, + }, + }); + expect(getExpectedResourcesDifference(expectedResourcesB, expectedResourcesA)).toEqual({ + expected: { + cpu: resultC, + memory: resultC, + disk: resultC, + }, + }); + }); + it('calculates resources sum correctly', () => { + const resultA: ResourcesWithUnits = { + value: 20, + original: 20, + units: ResourcesUnits.BYTES, + }; + const resultB: ResourcesWithUnits = { + value: 30, + original: 30, + units: ResourcesUnits.BYTES, + }; + + expect(getResourcesSum(resourcesA, resourcesA)).toEqual(resultA); + expect(getResourcesSum(resourcesA, resourcesB)).toEqual(resultB); + expect(getResourcesSum(resourcesB, resourcesA)).toEqual(resultB); + expect(getResourcesSum(resourcesB, resourcesC)).toBeNull(); + }); + it('formats version correctly', () => { + expect(formatDBClusterVersion('percona/percona-xtradb-cluster:8.0.22-13.1')).toBe('8.0.22'); + expect(formatDBClusterVersion('percona/percona-xtradb-cluster:5.6.2')).toBe('5.6.2'); + expect(formatDBClusterVersion(undefined)).toBe(''); + }); + it('formats version correctly with build number', () => { + expect(formatDBClusterVersionWithBuild('percona/percona-xtradb-cluster:8.0.22-13.1')).toBe('8.0.22-13.1'); + expect(formatDBClusterVersionWithBuild('percona/percona-xtradb-cluster:5.6.2')).toBe('5.6.2'); + expect(formatDBClusterVersionWithBuild(undefined)).toBe(''); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBCluster.utils.tsx b/public/app/percona/dbaas/components/DBCluster/DBCluster.utils.tsx new file mode 100644 index 0000000000000..aef2107d713d1 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBCluster.utils.tsx @@ -0,0 +1,99 @@ +import { SelectableValue } from '@grafana/data'; +import { Databases } from 'app/percona/shared/core'; +import { + DBCluster, + DBClusterExpectedResources, + DBClusterPayload, + DBClusterStatus, + ResourcesUnits, + ResourcesWithUnits, +} from './DBCluster.types'; +import { SERVICE_MAP, THOUSAND } from './DBCluster.constants'; +import { DBClusterService } from './DBCluster.service'; +import { Kubernetes } from '../Kubernetes/Kubernetes.types'; + +export const isClusterChanging = ({ status }: DBCluster) => { + const isChanging = status === DBClusterStatus.changing || status === DBClusterStatus.deleting; + + return isChanging; +}; + +export const newDBClusterService = (type: Databases): DBClusterService => { + const service = SERVICE_MAP[type] as DBClusterService; + + return service || SERVICE_MAP[Databases.mysql]; +}; + +export const isOptionEmpty = (option?: SelectableValue) => !option || Object.keys(option).length === 0 || !option.value; + +export const formatResources = (bytes: number, decimals: number): ResourcesWithUnits => { + const i = Math.floor(Math.log(bytes) / Math.log(THOUSAND)); + const units = Object.values(ResourcesUnits)[i]; + + return { value: parseFloat((bytes / Math.pow(THOUSAND, i)).toFixed(decimals)), units, original: bytes }; +}; + +export const getResourcesDifference = ( + { value: valueA, original: originalA, units: unitsA }: ResourcesWithUnits, + { value: valueB, original: originalB, units: unitsB }: ResourcesWithUnits +): ResourcesWithUnits | null => { + if (unitsA !== unitsB) { + return null; + } + + return { + original: originalA - originalB, + value: valueA - valueB, + units: unitsA, + }; +}; + +export const getResourcesSum = ( + { value: valueA, original: originalA, units: unitsA }: ResourcesWithUnits, + { value: valueB, original: originalB, units: unitsB }: ResourcesWithUnits +): ResourcesWithUnits | null => { + if (unitsA !== unitsB) { + return null; + } + + return { + original: originalA + originalB, + value: valueA + valueB, + units: unitsA, + }; +}; + +export const getExpectedResourcesDifference = ( + { expected: { cpu: cpuA, memory: memoryA, disk: diskA } }: DBClusterExpectedResources, + { expected: { cpu: cpuB, memory: memoryB, disk: diskB } }: DBClusterExpectedResources +): DBClusterExpectedResources => { + return { + expected: { + cpu: getResourcesDifference(cpuA, cpuB) as ResourcesWithUnits, + memory: getResourcesDifference(memoryA, memoryB) as ResourcesWithUnits, + disk: getResourcesDifference(diskA, diskB) as ResourcesWithUnits, + }, + }; +}; + +export const formatDBClusterVersion = (version?: string) => (version ? version.split(':')[1].split('-')[0] : ''); + +export const formatDBClusterVersionWithBuild = (version?: string) => (version ? version.split(':')[1] : ''); + +const clustersToModel = (database: Databases, clusters: DBClusterPayload[], kubernetes: Kubernetes[], index: number) => + clusters.map((cluster) => { + return newDBClusterService(database).toModel(cluster, kubernetes[index].kubernetesClusterName, database); + }); + +export const formatDBClusters = (results: any[], kubernetes: Kubernetes[]) => { + const clustersList: DBCluster[] = results.reduce((acc: DBCluster[], r, index) => { + const pxcClusters: DBClusterPayload[] = r.pxc_clusters ?? []; + const psmdbClusters: DBClusterPayload[] = r.psmdb_clusters ?? []; + const pxcClustersModel = clustersToModel(Databases.mysql, pxcClusters, kubernetes, index); + const psmdbClustersModel = clustersToModel(Databases.mongodb, psmdbClusters, kubernetes, index); + + return acc.concat([...pxcClustersModel, ...psmdbClustersModel]); + }, []); + + return clustersList; +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.styles.ts new file mode 100644 index 0000000000000..c8191103b7a73 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.styles.ts @@ -0,0 +1,8 @@ +import { css } from '@emotion/css'; + +export const styles = { + actionsColumn: css` + display: flex; + justify-content: center; + `, +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.test.tsx new file mode 100644 index 0000000000000..70fd8023245ff --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; +import { DBClusterActions } from './DBClusterActions'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +jest.mock('app/core/app_events'); +jest.mock('../XtraDB.service'); + +describe('DBClusterActions::', () => { + it('renders correctly', async () => { + render( + + ); + + expect(screen.getByTestId('dropdown-menu-toggle')).toBeInTheDocument(); + }); + + it('doesnt disable button if cluster is ready', () => { + render( + + ); + + expect(screen.getByRole('button')).not.toBeDisabled(); + }); + + it('calls delete action correctly', async () => { + const setSelectedCluster = jest.fn(); + const setDeleteModalVisible = jest.fn(); + render( + + ); + + const btn = screen.getByRole('button'); + await waitFor(() => fireEvent.click(btn)); + + const action = screen.getByTestId('dropdown-menu-menu').querySelectorAll('span')[1]; + await waitFor(() => fireEvent.click(action)); + + expect(setSelectedCluster).toHaveBeenCalled(); + expect(setDeleteModalVisible).toHaveBeenCalled(); + }); + + it('delete action is disabled if cluster is deleting', async () => { + const setSelectedCluster = jest.fn(); + const setDeleteModalVisible = jest.fn(); + render( + + ); + + const btn = screen.getByRole('button'); + await waitFor(() => fireEvent.click(btn)); + + const action = screen.getByTestId('dropdown-menu-menu').querySelectorAll('span')[1]; + await waitFor(() => fireEvent.click(action)); + + expect(setSelectedCluster).toHaveBeenCalled(); + expect(setDeleteModalVisible).toHaveBeenCalled(); + }); + + xit('calls restart action correctly', async () => { + const getDBClusters = jest.fn(); + render( + + ); + + const btn = screen.getByRole('button'); + await waitFor(() => fireEvent.click(btn)); + + const action = screen.getByTestId('dropdown-menu-menu').querySelectorAll('span')[3]; + await waitFor(() => fireEvent.click(action)); + + expect(getDBClusters).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.tsx new file mode 100644 index 0000000000000..a520d312504c8 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.tsx @@ -0,0 +1,108 @@ +import React, { FC, useCallback } from 'react'; +import { logger } from '@percona/platform-core'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { MultipleActions } from 'app/percona/dbaas/components/MultipleActions/MultipleActions'; +import { DBCluster, DBClusterStatus } from '../DBCluster.types'; +import { isClusterChanging, newDBClusterService } from '../DBCluster.utils'; +import { DBClusterActionsProps } from './DBClusterActions.types'; +import { styles } from './DBClusterActions.styles'; + +export const DBClusterActions: FC = ({ + dbCluster, + setSelectedCluster, + setDeleteModalVisible, + setEditModalVisible, + setLogsModalVisible, + setUpdateModalVisible, + getDBClusters, +}) => { + const getActions = useCallback( + (dbCluster: DBCluster) => [ + { + title: Messages.dbcluster.table.actions.updateCluster, + disabled: + !dbCluster.availableImage || + dbCluster.status === DBClusterStatus.upgrading || + dbCluster.status === DBClusterStatus.deleting || + dbCluster.status === DBClusterStatus.changing, + action: () => { + setSelectedCluster(dbCluster); + setUpdateModalVisible(true); + }, + }, + { + title: Messages.dbcluster.table.actions.deleteCluster, + disabled: dbCluster.status === DBClusterStatus.deleting, + action: () => { + setSelectedCluster(dbCluster); + setDeleteModalVisible(true); + }, + }, + { + title: Messages.dbcluster.table.actions.editCluster, + disabled: dbCluster.status !== DBClusterStatus.ready, + action: () => { + setSelectedCluster(dbCluster); + setEditModalVisible(true); + }, + }, + { + title: Messages.dbcluster.table.actions.restartCluster, + disabled: isClusterChanging(dbCluster), + action: async () => { + try { + const dbClusterService = newDBClusterService(dbCluster.databaseType); + + await dbClusterService.restartDBCluster(dbCluster); + getDBClusters(); + } catch (e) { + logger.error(e); + } + }, + }, + { + title: + dbCluster.status === DBClusterStatus.ready + ? Messages.dbcluster.table.actions.suspend + : Messages.dbcluster.table.actions.resume, + disabled: dbCluster.status !== DBClusterStatus.ready && dbCluster.status !== DBClusterStatus.suspended, + action: async () => { + try { + const dbClusterService = newDBClusterService(dbCluster.databaseType); + + if (dbCluster.status === DBClusterStatus.ready) { + await dbClusterService.suspendDBCluster(dbCluster); + } else { + await dbClusterService.resumeDBCluster(dbCluster); + } + + getDBClusters(); + } catch (e) { + logger.error(e); + } + }, + }, + { + title: Messages.dbcluster.table.actions.logs, + action: () => { + setSelectedCluster(dbCluster); + setLogsModalVisible(true); + }, + }, + ], + [ + setSelectedCluster, + setDeleteModalVisible, + getDBClusters, + setEditModalVisible, + setLogsModalVisible, + setUpdateModalVisible, + ] + ); + + return ( +
    + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.types.ts new file mode 100644 index 0000000000000..235c3a106a540 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterActions/DBClusterActions.types.ts @@ -0,0 +1,11 @@ +import { DBCluster, GetDBClustersAction } from '../DBCluster.types'; + +export interface DBClusterActionsProps { + dbCluster: DBCluster; + setSelectedCluster: (dbCluster: DBCluster) => void; + setDeleteModalVisible: (isVisible: boolean) => void; + setEditModalVisible: (isVisible: boolean) => void; + setLogsModalVisible: (isVisible: boolean) => void; + setUpdateModalVisible: (isVisible: boolean) => void; + getDBClusters: GetDBClustersAction; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.constants.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.constants.ts new file mode 100644 index 0000000000000..fdced3b16d539 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.constants.ts @@ -0,0 +1,6 @@ +export const INITIAL_CONNECTION = { + host: '', + username: '', + port: 0, + password: '', +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.styles.ts new file mode 100644 index 0000000000000..f2311fccf5c07 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.styles.ts @@ -0,0 +1,10 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + connectionWrapper: css` + display: flex; + flex-direction: column; + margin: ${spacing.xs} ${spacing.sm}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.test.tsx new file mode 100644 index 0000000000000..b6ded2a053f99 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { DBClusterConnection } from './DBClusterConnection'; +import { dbClustersStub, mongoDBClusterConnectionStub } from '../__mocks__/dbClustersStubs'; +import { render, screen, waitFor } from '@testing-library/react'; + +jest.mock('app/core/app_events'); +jest.mock('../XtraDB.service'); +jest.mock('../PSMDB.service'); + +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); + +describe('DBClusterConnection::', () => { + it('renders correctly connection items', async () => { + await waitFor(() => render()); + + expect(screen.getByTestId('cluster-connection-host')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-connection-port')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-connection-username')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-connection-password')).toBeInTheDocument(); + }); + it('renders correctly connection items with MongoDB cluster', async () => { + await waitFor(() => render()); + + expect(screen.getByTestId('cluster-connection-host')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-connection-host')).toHaveTextContent(mongoDBClusterConnectionStub.host); + expect(screen.getByTestId('cluster-connection-port')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-connection-username')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-connection-password')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.tsx new file mode 100644 index 0000000000000..f4806c25e05e3 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.tsx @@ -0,0 +1,72 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useStyles } from '@grafana/ui'; +import { logger } from '@percona/platform-core'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DBClusterConnectionProps } from './DBClusterConnection.types'; +import { DBClusterConnection as ConnectionParams, DBClusterStatus, DBClusterConnectionAPI } from '../DBCluster.types'; +import { INITIAL_CONNECTION } from './DBClusterConnection.constants'; +import { getStyles } from './DBClusterConnection.styles'; +import { DBClusterConnectionPassword } from './DBClusterConnectionPassword/DBClusterConnectionPassword'; +import { DBClusterConnectionItem } from './DBClusterConnectionItem/DBClusterConnectionItem'; +import { newDBClusterService } from '../DBCluster.utils'; + +export const DBClusterConnection: FC = ({ dbCluster }) => { + const styles = useStyles(getStyles); + const [loading, setLoading] = useState(true); + const [connection, setConnection] = useState(INITIAL_CONNECTION); + const { host, password, port, username } = connection; + const { status, databaseType } = dbCluster; + const isClusterReady = status && status === DBClusterStatus.ready; + + useEffect(() => { + const getClusterConnection = async () => { + try { + setLoading(true); + const dbClusterService = newDBClusterService(databaseType); + const connection = (await dbClusterService.getDBClusterCredentials(dbCluster)) as DBClusterConnectionAPI; + + setConnection(connection.connection_credentials); + } catch (e) { + logger.error(e); + } finally { + setLoading(false); + } + }; + + if (isClusterReady) { + getClusterConnection(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + return ( + <> +
    + {!loading && isClusterReady && ( + <> + + + + + + )} +
    + + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.types.ts new file mode 100644 index 0000000000000..006329e73dce2 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnection.types.ts @@ -0,0 +1,5 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface DBClusterConnectionProps { + dbCluster: DBCluster; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.styles.ts new file mode 100644 index 0000000000000..fcdc9f66ef966 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.styles.ts @@ -0,0 +1,15 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + connectionItemWrapper: css` + display: flex; + margin-bottom: ${spacing.xxs}; + `, + connectionItemLabel: css` + font-weight: ${typography.weight.bold}; + `, + connectionItemValue: css` + margin-left: ${spacing.xs}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.test.tsx new file mode 100644 index 0000000000000..2ebac645c239d --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { DBClusterConnectionItem } from './DBClusterConnectionItem'; +import { render } from '@testing-library/react'; + +describe('DBClusterConnectionItem::', () => { + it('renders correctly', () => { + const { container } = render(); + + expect(container.querySelectorAll('span')).toHaveLength(2); + expect(container.querySelector('div')?.children).toHaveLength(2); + }); + it('renders correctly label and value', () => { + const { container } = render(); + + expect(container).toHaveTextContent('test label'); + expect(container).toHaveTextContent('test value'); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.tsx new file mode 100644 index 0000000000000..849a92effd82d --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.tsx @@ -0,0 +1,15 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { getStyles } from './DBClusterConnectionItem.styles'; +import { DBClusterConnectionItemProps } from './DBClusterConnectionItem.types'; + +export const DBClusterConnectionItem: FC = ({ label, value, dataTestId }) => { + const styles = useStyles(getStyles); + + return ( +
    + {label}: + {value} +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.types.ts new file mode 100644 index 0000000000000..33270082590da --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem.types.ts @@ -0,0 +1,5 @@ +export interface DBClusterConnectionItemProps { + label: string; + value: string | number; + dataTestId?: string; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.constants.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.constants.ts new file mode 100644 index 0000000000000..1c0f7e1d98178 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.constants.ts @@ -0,0 +1 @@ +export const HIDDEN_PASSWORD_LENGTH = 15; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.styles.ts new file mode 100644 index 0000000000000..fcdcdd5984ecd --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.styles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + connectionPasswordWrapper: css` + display: flex; + `, + showPasswordButton: css` + margin: 0 0 0 ${spacing.md}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.test.tsx new file mode 100644 index 0000000000000..e25a267aeac5b --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { DBClusterConnectionPassword } from './DBClusterConnectionPassword'; +import { HIDDEN_PASSWORD_LENGTH } from './DBClusterConnectionPassword.constants'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('DBClusterConnectionPassword::', () => { + it('renders correctly', () => { + const { container } = render(); + + expect(container.querySelectorAll('div')[0].children).toHaveLength(2); + expect(container).toHaveTextContent('Test'); + expect(container).toHaveTextContent('*'.repeat(HIDDEN_PASSWORD_LENGTH)); + }); + it('should show/hide password', () => { + const { container } = render(); + const button = screen.getByTestId('show-password-button'); + + expect(container).toHaveTextContent('*'.repeat(HIDDEN_PASSWORD_LENGTH)); + + fireEvent.click(button); + + expect(container).toHaveTextContent('1234'); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.tsx new file mode 100644 index 0000000000000..fcd8aea57e960 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.tsx @@ -0,0 +1,28 @@ +import React, { FC, useMemo, useState } from 'react'; +import { IconButton, useStyles } from '@grafana/ui'; +import { DBClusterConnectionItem } from '../DBClusterConnectionItem/DBClusterConnectionItem'; +import { DBClusterConnectionPasswordProps } from './DBClusterConnectionPassword.types'; +import { HIDDEN_PASSWORD_LENGTH } from './DBClusterConnectionPassword.constants'; +import { getStyles } from './DBClusterConnectionPassword.styles'; + +export const DBClusterConnectionPassword: FC = ({ label, password, dataTestId }) => { + const styles = useStyles(getStyles); + const [showPassword, setShowPassword] = useState(false); + const getHiddenPassword = useMemo(() => '*'.repeat(HIDDEN_PASSWORD_LENGTH), []); + + return ( +
    + + setShowPassword(!showPassword)} + className={styles.showPasswordButton} + /> +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.types.ts new file mode 100644 index 0000000000000..cd2512372bc41 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterConnection/DBClusterConnectionPassword/DBClusterConnectionPassword.types.ts @@ -0,0 +1,5 @@ +export interface DBClusterConnectionPasswordProps { + label: string; + password: string; + dataTestId?: string; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.test.tsx new file mode 100644 index 0000000000000..0c71a113fd39d --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ContainerLogs } from './ContainerLogs'; +import { render } from '@testing-library/react'; + +describe('ContainerLogs::', () => { + it('renders container name and logs', () => { + const { container } = render( + + ); + + expect(container.querySelector('div > div > div > div ')).toHaveTextContent('Test'); + expect(container.querySelector('pre')).toHaveTextContent('Test logs'); + }); + + it("does't render logs when collapsed", () => { + const { container } = render( + + ); + + expect(container.querySelector('pre')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.tsx new file mode 100644 index 0000000000000..24d965f6e6921 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.tsx @@ -0,0 +1,16 @@ +import React, { FC, useState } from 'react'; +import { Collapse } from '@grafana/ui'; +import { ContainerLogsProps } from './ContainerLogs.types'; + +export const ContainerLogs: FC = ({ containerLogs }) => { + const { name, isOpen: isContainerOpen, logs } = containerLogs; + const [isOpen, setIsOpen] = useState(isContainerOpen); + + return ( +
    + setIsOpen(!isOpen)}> +
    {logs}
    +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.types.ts new file mode 100644 index 0000000000000..c8df2992e45bc --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/ContainerLogs/ContainerLogs.types.ts @@ -0,0 +1,5 @@ +import { DBClusterContainerLogs } from '../../DBCluster.types'; + +export interface ContainerLogsProps { + containerLogs: DBClusterContainerLogs; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.messages.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.messages.ts new file mode 100644 index 0000000000000..876793705e82c --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.messages.ts @@ -0,0 +1,7 @@ +export const Messages = { + title: 'View Cluster Logs', + pods: 'Pods', + expand: 'Expand all', + collapse: 'Collapse all', + noLogs: 'There are no logs yet', +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.styles.ts new file mode 100644 index 0000000000000..697cbe4a05e31 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.styles.ts @@ -0,0 +1,35 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + modalWrapper: css` + max-height: 75vh; + overflow: auto; + padding: ${spacing.xs}; + `, + spinnerWrapper: css` + align-items: center; + display: flex; + justify-content: center; + `, + header: css` + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: ${spacing.md}; + `, + podsLabel: css` + flex: 1; + font-size: ${typography.size.lg}; + `, + expandButton: css` + margin-right: ${spacing.md}; + `, + modal: css` + div[data-testid='modal-body'] { + max-width: unset; + width: 80%; + } + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.test.tsx new file mode 100644 index 0000000000000..851a618b950b3 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, act, fireEvent, within } from '@testing-library/react'; +import { DBClusterLogsModal } from './DBClusterLogsModal'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; +import { DBClusterService } from '../__mocks__/DBCluster.service'; + +jest.mock('../DBCluster.service'); + +describe('DBClusterLogsModal::', () => { + it('should render logs', async () => { + act(() => { + render(); + }); + const logs = await screen.findAllByTestId('dbcluster-pod-logs'); + expect(logs.length).toBeGreaterThan(0); + }); + + it('should expand logs', async () => { + act(() => { + render(); + }); + + let preTags = screen.queryAllByTestId('dbcluster-pod-events'); + let logs = screen.queryAllByTestId('dbcluster-logs'); + + expect(preTags).toHaveLength(0); + expect(logs).toHaveLength(0); + + const actions = await screen.findAllByTestId('dbcluster-logs-actions'); + const expandButton = within(actions[0]).getAllByRole('button')[0]; + + act(() => { + fireEvent.click(expandButton); + }); + + logs = await screen.findAllByTestId('dbcluster-logs'); + expect(logs.length).toBeGreaterThan(0); + }); + + it('should refresh logs', async () => { + const getLogs = jest.fn(); + + act(() => { + render(); + }); + + DBClusterService.getLogs = getLogs(); + + const actions = await screen.findAllByTestId('dbcluster-logs-actions'); + const refreshButton = within(actions[0]).getAllByRole('button')[1]; + + act(() => { + fireEvent.click(refreshButton); + }); + + await screen.findAllByTestId('dbcluster-logs-actions'); + + expect(getLogs).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.tsx new file mode 100644 index 0000000000000..5b508e5ba2525 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.tsx @@ -0,0 +1,84 @@ +import React, { FC, useEffect, useState } from 'react'; +import { Button, Icon, Spinner, useStyles } from '@grafana/ui'; +import { Modal, logger } from '@percona/platform-core'; +import { DBClusterLogsModalProps } from './DBClusterLogsModal.types'; +import { DBClusterService } from '../DBCluster.service'; +import { Messages } from './DBClusterLogsModal.messages'; +import { toggleLogs, transformLogs } from './DBClusterLogsModal.utils'; +import { DBClusterLogs } from '../DBCluster.types'; +import { PodLogs } from './PodLogs/PodLogs'; +import { getStyles } from './DBClusterLogsModal.styles'; + +export const DBClusterLogsModal: FC = ({ dbCluster, isVisible, setVisible }) => { + const styles = useStyles(getStyles); + const [loading, setLoading] = useState(false); + const [logs, setLogs] = useState({ pods: [] }); + const [expanded, setExpanded] = useState(false); + const getClusterLogs = async () => { + if (!dbCluster) { + return; + } + + try { + setLoading(true); + setLogs(transformLogs(await DBClusterService.getLogs(dbCluster), logs)); + } catch (e) { + logger.error(e); + } finally { + setLoading(false); + } + }; + + const toggleCollapse = () => { + setLogs({ pods: toggleLogs(logs.pods, !expanded) }); + setExpanded((currentValue) => !currentValue); + }; + const refresh = () => { + getClusterLogs(); + setExpanded(false); + }; + + useEffect(() => { + getClusterLogs(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbCluster]); + + useEffect(() => { + setExpanded(false); + }, [isVisible]); + + return ( +
    + setVisible(false)}> +
    + {loading ? ( +
    + +
    + ) : ( + <> + {!logs || logs.pods.length <= 0 ? ( + {Messages.noLogs} + ) : ( + <> +
    + {Messages.pods} + + +
    + {logs.pods.map((pod) => ( + + ))} + + )} + + )} +
    +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.types.ts new file mode 100644 index 0000000000000..178947042bc86 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.types.ts @@ -0,0 +1,11 @@ +import { DBCluster, DBClusterPodLogs } from '../DBCluster.types'; + +export interface DBClusterLogsModalProps { + dbCluster: DBCluster | undefined; + isVisible: boolean; + setVisible: (value: boolean) => void; +} + +export interface DBClusterLogsMap { + [key: string]: DBClusterPodLogs; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.test.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.test.ts new file mode 100644 index 0000000000000..facefe2b36c42 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.test.ts @@ -0,0 +1,132 @@ +import { DBClusterLogs } from '../DBCluster.types'; +import { dbClusterLogsAPI } from '../__mocks__/dbClustersStubs'; +import { logsToString, toggleLogs, transformLogs } from './DBClusterLogsModal.utils'; + +describe('DBClusterLogsModal.utils::', () => { + describe('transformLogs::', () => { + it('transforms logs to expected format', () => { + const expectedLogs: DBClusterLogs = { + pods: [ + { + name: 'testpod1', + isOpen: false, + events: 'test pod1\nevents', + containers: [ + { + name: 'testpod1container1', + isOpen: false, + logs: 'test pod1\nlogs\n1', + }, + { + name: 'testpod1container2', + isOpen: false, + logs: 'test pod1\nlogs\n2', + }, + ], + }, + { + name: 'testpod2', + isOpen: false, + events: 'test pod2\nevents', + containers: [ + { + name: 'testpod2container1', + isOpen: false, + logs: 'test pod2\nlogs\n1', + }, + { + name: 'testpod2container2', + isOpen: false, + logs: '', + }, + ], + }, + ], + }; + + expect(transformLogs(dbClusterLogsAPI)).toEqual(expectedLogs); + }); + }); + describe('logsToString::', () => { + it('parses array of logs to string', () => { + const logs = ['list', 'was', 'updated']; + + expect(logsToString(logs)).toEqual('list\nwas\nupdated'); + }); + it('handles empty logs', () => { + expect(logsToString([])).toEqual(''); + }); + it('handles undefined logs', () => { + expect(logsToString(undefined)).toEqual(''); + }); + }); + describe('toggleLogs::', () => { + const logs = { + pods: [ + { + name: 'testpod1', + isOpen: true, + events: 'test pod1\nevents', + containers: [ + { + name: 'testpod1container1', + isOpen: false, + logs: 'test pod1\nlogs\n1', + }, + { + name: 'testpod1container2', + isOpen: false, + logs: 'test pod1\nlogs\n2', + }, + ], + }, + ], + }; + it('expands all the logs', () => { + const expectedLogs = [ + { + name: 'testpod1', + isOpen: true, + events: 'test pod1\nevents', + containers: [ + { + name: 'testpod1container1', + isOpen: true, + logs: 'test pod1\nlogs\n1', + }, + { + name: 'testpod1container2', + isOpen: true, + logs: 'test pod1\nlogs\n2', + }, + ], + }, + ]; + + expect(toggleLogs(logs.pods, true)).toEqual(expectedLogs); + }); + it('collapses all the logs', () => { + const expectedLogs = [ + { + name: 'testpod1', + isOpen: false, + events: 'test pod1\nevents', + containers: [ + { + name: 'testpod1container1', + isOpen: false, + logs: 'test pod1\nlogs\n1', + }, + { + name: 'testpod1container2', + isOpen: false, + logs: 'test pod1\nlogs\n2', + }, + ], + }, + ]; + + expect(toggleLogs(logs.pods, false)).toEqual(expectedLogs); + }); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.ts new file mode 100644 index 0000000000000..ba52a528d7978 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/DBClusterLogsModal.utils.ts @@ -0,0 +1,42 @@ +import { DBClusterLogs, DBClusterLogsAPI, DBClusterPodLogs, DBClusterContainerLogs } from '../DBCluster.types'; +import { DBClusterLogsMap } from './DBClusterLogsModal.types'; + +export const transformLogs = ({ logs }: DBClusterLogsAPI, currentLogs?: DBClusterLogs): DBClusterLogs => { + const logsMap: DBClusterLogsMap = logs.reduce((acc, { pod, container, logs }) => { + if (!acc[pod]) { + acc[pod] = { name: pod, isOpen: false, events: '', containers: [] }; + } + + // if pod has container, logs are for that container + // otherwise they are pod events + if (container) { + acc[pod].containers = [ + ...acc[pod].containers, + { + name: container, + isOpen: false, + logs: logsToString(logs), + }, + ]; + } else { + acc[pod].events = logsToString(logs); + } + + return acc; + }, {} as DBClusterLogsMap); + + return { pods: Object.values(logsMap) }; +}; + +export const logsToString = (logs?: string[]) => (logs ? logs.join('\n') : ''); + +export const toggleLogs = (pods: DBClusterPodLogs[], expand: boolean) => { + return pods.reduce((accPods, pod) => { + const containers = pod.containers.reduce( + (accContainers, container) => [...accContainers, { ...container, isOpen: expand }], + [] as DBClusterContainerLogs[] + ); + + return [...accPods, { ...pod, isOpen: expand, containers }]; + }, [] as DBClusterPodLogs[]); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.messages.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.messages.ts new file mode 100644 index 0000000000000..49318029ecf42 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.messages.ts @@ -0,0 +1,4 @@ +export const Messages = { + events: 'Events', + containers: 'Containers', +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.styles.ts new file mode 100644 index 0000000000000..29a0e3b8c480e --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.styles.ts @@ -0,0 +1,12 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + label: css` + font-size: ${typography.size.lg}; + margin-bottom: ${spacing.md}; + `, + labelSpacing: css` + margin-top: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.test.tsx new file mode 100644 index 0000000000000..710e9ee3e9c7e --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { PodLogs } from './PodLogs'; +import { render, screen } from '@testing-library/react'; + +describe('PodLogs::', () => { + it('renders pod name, events and containers', () => { + const { container } = render( + + ); + + expect(container).toHaveTextContent('Pod name'); + expect(screen.getByTestId('dbcluster-pod-events')).toHaveTextContent('Test events'); + expect(screen.getByTestId('dbcluster-containers').children).toHaveLength(2); + }); + + it("doesn't render logs when collapsed", () => { + render( + + ); + + expect(screen.queryByTestId('dbcluster-pod-events')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dbcluster-containers')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.tsx new file mode 100644 index 0000000000000..3b1cff54f29a5 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.tsx @@ -0,0 +1,29 @@ +import React, { FC, useState } from 'react'; +import { Collapse, useStyles } from '@grafana/ui'; +import { PodLogsProps } from './PodLogs.types'; +import { Messages } from './PodLogs.messages'; +import { ContainerLogs } from '../ContainerLogs/ContainerLogs'; +import { getStyles } from './PodLogs.styles'; + +export const PodLogs: FC = ({ podLogs }) => { + const styles = useStyles(getStyles); + const { name, isOpen: isPodOpen, containers, events } = podLogs; + const [isOpen, setIsOpen] = useState(isPodOpen); + + return ( +
    + setIsOpen(!isOpen)}> + {Messages.events} +
    +          {events}
    +        
    + {Messages.containers} +
    + {containers.map((container) => ( + + ))} +
    +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.types.ts new file mode 100644 index 0000000000000..c7850a4f8a5f8 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterLogsModal/PodLogs/PodLogs.types.ts @@ -0,0 +1,5 @@ +import { DBClusterPodLogs } from '../../DBCluster.types'; + +export interface PodLogsProps { + podLogs: DBClusterPodLogs; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.constants.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.constants.ts new file mode 100644 index 0000000000000..3b4baffba6ef4 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.constants.ts @@ -0,0 +1,10 @@ +/* eslint implicit-arrow-linebreak: 0 */ +import { Databases } from 'app/percona/shared/core'; +import { DashboardURLMap } from './DBClusterName.types'; + +export const DASHBOARD_URL_MAP: DashboardURLMap = { + [Databases.mysql]: (clusterName: string) => + `/graph/d/pxc-cluster-summary/pxc-galera-cluster-summary?var-cluster=${clusterName}-pxc`, + [Databases.mongodb]: (clusterName: string) => + `/graph/d/mongodb-cluster-summary/mongodb-cluster-summary?var-cluster=${clusterName}`, +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.styles.ts new file mode 100644 index 0000000000000..ebed3f1f91ea0 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.styles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + clusterNameWrapper: css` + display: flex; + `, + dashboardIcon: css` + margin-left: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.test.tsx new file mode 100644 index 0000000000000..d0ac013d224d9 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Icon } from '@grafana/ui'; +import { DBClusterName } from './DBClusterName'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; +import { render, screen } from '@testing-library/react'; + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + Icon: jest.fn(() =>
    ), +})); + +describe('DBClusterName::', () => { + it('renders correctly with name and icon', () => { + const cluster = dbClustersStub[0]; + const { container } = render(); + + expect(container).toHaveTextContent(cluster.clusterName); + expect(Icon).toHaveBeenCalled(); + }); + it('renders correctly MySQL cluster URL', () => { + const cluster = dbClustersStub[0]; + render(); + + expect(screen.getByRole('link').getAttribute('href')).toContain('pxc'); + expect(screen.getByRole('link').getAttribute('href')).toContain(cluster.clusterName); + }); + it('renders correctly MongoDB cluster URL', () => { + const cluster = dbClustersStub[2]; + render(); + + expect(screen.getByRole('link').getAttribute('href')).toContain('mongodb'); + expect(screen.getByRole('link').getAttribute('href')).toContain(cluster.clusterName); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.tsx new file mode 100644 index 0000000000000..05568eedac348 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { Icon, useStyles } from '@grafana/ui'; +import { getStyles } from './DBClusterName.styles'; +import { DBClusterNameProps } from './DBClusterName.types'; +import { DASHBOARD_URL_MAP } from './DBClusterName.constants'; + +export const DBClusterName: FC = ({ dbCluster: { clusterName, databaseType } }) => { + const styles = useStyles(getStyles); + const getDashboardUrl = DASHBOARD_URL_MAP[databaseType]; + + return ( +
    + {clusterName} + + + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.types.ts new file mode 100644 index 0000000000000..ce2c666d27803 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterName/DBClusterName.types.ts @@ -0,0 +1,9 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface DBClusterNameProps { + dbCluster: DBCluster; +} + +export interface DashboardURLMap { + [key: string]: (clusterName: string) => string; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.styles.ts new file mode 100644 index 0000000000000..97213323baaef --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.styles.ts @@ -0,0 +1,14 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + wrapper: css` + display: flex; + flex-direction: column; + margin: ${spacing.xs} ${spacing.sm}; + `, + parametersFailed: css` + display: flex; + justify-content: center; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.test.tsx new file mode 100644 index 0000000000000..19bccdb8b4234 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { DBClusterParameters } from './DBClusterParameters'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; +import { render, screen } from '@testing-library/react'; + +jest.mock('app/core/app_events'); +jest.mock('../XtraDB.service'); +jest.mock('../PSMDB.service'); + +describe('DBClusterParameters::', () => { + it('renders parameters items correctly', async () => { + render(); + + expect(screen.getByTestId('cluster-parameters-cluster-name')).toBeInTheDocument(); + + const memory = screen.getByTestId('cluster-parameters-memory'); + const cpu = screen.getByTestId('cluster-parameters-cpu'); + const disk = screen.getByTestId('cluster-parameters-disk'); + const expose = screen.getByTestId('cluster-parameters-expose'); + + expect(memory).toBeInTheDocument(); + expect(memory).toHaveTextContent('Memory:1024 GB'); + expect(cpu).toBeInTheDocument(); + expect(cpu).toHaveTextContent('CPU:1'); + expect(disk).toBeInTheDocument(); + expect(disk).toHaveTextContent('Disk:25 GB'); + expect(expose).toBeInTheDocument(); + expect(expose).toHaveTextContent('External Access:Enabled'); + }); + + it('renders parameters items correctly with MongoDB cluster', async () => { + render(); + + expect(screen.getByTestId('cluster-parameters-cluster-name')).toBeInTheDocument(); + + const memory = screen.getByTestId('cluster-parameters-memory'); + const cpu = screen.getByTestId('cluster-parameters-cpu'); + const disk = screen.getByTestId('cluster-parameters-disk'); + const expose = screen.getByTestId('cluster-parameters-expose'); + + expect(memory).toBeInTheDocument(); + expect(memory).toHaveTextContent('Memory:0 GB'); + expect(cpu).toBeInTheDocument(); + expect(cpu).toHaveTextContent('CPU:0'); + expect(disk).toBeInTheDocument(); + expect(disk).toHaveTextContent('Disk:25 GB'); + expect(expose).toBeInTheDocument(); + expect(expose).toHaveTextContent('External Access:Disabled'); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.tsx new file mode 100644 index 0000000000000..c8fda5b9eb000 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.tsx @@ -0,0 +1,51 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DBClusterParametersProps } from './DBClusterParameters.types'; +import { DBClusterStatus } from '../DBCluster.types'; +import { getStyles } from './DBClusterParameters.styles'; +import { DBClusterConnectionItem } from '../DBClusterConnection/DBClusterConnectionItem/DBClusterConnectionItem'; + +export const DBClusterParameters: FC = ({ dbCluster }) => { + const styles = useStyles(getStyles); + const { status } = dbCluster; + const { + label: exposeLabel, + enabled: exposeEnabled, + disabled: exposeDisabled, + } = Messages.dbcluster.table.parameters.expose; + + return ( + <> + {status && status === DBClusterStatus.ready && ( +
    + + + + + +
    + )} + + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.types.ts new file mode 100644 index 0000000000000..9ba7c96716f4b --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterParameters/DBClusterParameters.types.ts @@ -0,0 +1,5 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface DBClusterParametersProps { + dbCluster: DBCluster; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.constants.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.constants.ts new file mode 100644 index 0000000000000..844780c080403 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.constants.ts @@ -0,0 +1,14 @@ +import { DBClusterStatus } from '../DBCluster.types'; + +export const STATUS_DATA_QA = { + [DBClusterStatus.changing]: 'pending', + [DBClusterStatus.deleting]: 'deleting', + [DBClusterStatus.failed]: 'failed', + [DBClusterStatus.invalid]: 'invalid', + [DBClusterStatus.ready]: 'active', + [DBClusterStatus.suspended]: 'suspended', + [DBClusterStatus.upgrading]: 'updating', + [DBClusterStatus.unknown]: 'unknown', +}; + +export const COMPLETE_PROGRESS_DELAY = 4000; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.styles.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.styles.ts new file mode 100644 index 0000000000000..b6b412c3a622d --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.styles.ts @@ -0,0 +1,54 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1, typography, colors }: GrafanaTheme2) => ({ + clusterStatusWrapper: css` + align-items: flex-end; + display: flex; + flex-direction: column; + justify-content: center; + margin: ${v1.spacing.sm}; + min-width: 125px; + padding: ${v1.spacing.xs} 0; + position: relative; + `, + clusterPillWrapper: css` + align-items: center; + min-width: 0; + `, + status: css` + background-color: ${v1.palette.gray1}; + border-radius: 20px; + color: ${v1.palette.gray85}; + cursor: default; + font-size: ${typography.size.sm}; + padding: 3px 15px; + text-transform: uppercase; + `, + statusIcon: css` + color: ${v1.palette.gray1}; + cursor: help; + margin-left: ${v1.spacing.xs}; + margin-bottom: 0px; + `, + statusActive: css` + background-color: ${colors.primary.main}; + label: active; + `, + statusFailed: css` + background-color: ${v1.palette.brandDanger}; + label: failed; + `, + logsWrapper: css` + bottom: ${v1.spacing.md}; + display: flex; + position: absolute; + `, + logsLabel: css` + font-size: ${typography.size.sm}; + color: ${v1.colors.linkExternal}; + &:hover { + color: ${v1.colors.textBlue}; + } + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.test.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.test.tsx new file mode 100644 index 0000000000000..d386f2fc95bf4 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { DBCluster, DBClusterStatus as Status } from '../DBCluster.types'; +import { DBClusterStatus } from './DBClusterStatus'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; +import { render, screen } from '@testing-library/react'; + +describe('DBClusterStatus::', () => { + it('renders correctly when active', () => { + const dbCluster: DBCluster = { + ...dbClustersStub[0], + status: Status.ready, + message: 'Should not render error', + finishedSteps: 10, + totalSteps: 10, + }; + const { container } = render( + + ); + + expect(screen.getByTestId('cluster-status-active')).toBeInTheDocument(); + expect(screen.queryByTestId('cluster-status-error-message')).not.toBeInTheDocument(); + expect(container.querySelector('span')?.className).toContain('active'); + }); + + it('renders progress bar and error when changing', () => { + const dbCluster: DBCluster = { + ...dbClustersStub[0], + status: Status.changing, + message: 'Should render error', + finishedSteps: 5, + totalSteps: 10, + }; + render(); + + expect(screen.queryByTestId('cluster-status-active')).not.toBeInTheDocument(); + expect(screen.getByTestId('cluster-progress-bar')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-status-error-message')).toBeInTheDocument(); + }); + + it('renders error and progress bar when failed', () => { + const dbCluster: DBCluster = { + ...dbClustersStub[0], + status: Status.failed, + message: 'Should render error', + finishedSteps: 10, + totalSteps: 10, + }; + render(); + + expect(screen.queryByTestId('cluster-status-active')).not.toBeInTheDocument(); + expect(screen.getByTestId('cluster-progress-bar')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-status-error-message')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.tsx b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.tsx new file mode 100644 index 0000000000000..9eb68b07c3a0b --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.tsx @@ -0,0 +1,88 @@ +/* eslint-disable react/display-name */ +import React, { FC, useMemo, useEffect, useRef, useState } from 'react'; +import { cx } from '@emotion/css'; +import { Icon, useStyles2, Tooltip } from '@grafana/ui'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { ProgressBar } from 'app/percona/dbaas/components/ProgressBar/ProgressBar'; +import { ProgressBarStatus } from 'app/percona/dbaas/components/ProgressBar/ProgressBar.types'; +import { DBClusterStatusProps } from './DBClusterStatus.types'; +import { getStyles } from './DBClusterStatus.styles'; +import { DBClusterStatus as Status } from '../DBCluster.types'; +import { COMPLETE_PROGRESS_DELAY, STATUS_DATA_QA } from './DBClusterStatus.constants'; +import { getProgressMessage, getShowProgressBarValue } from './DBClusterStatus.utils'; + +export const DBClusterStatus: FC = ({ dbCluster, setSelectedCluster, setLogsModalVisible }) => { + const { message, finishedSteps, totalSteps } = dbCluster; + const status = dbCluster.status as Status; + const styles = useStyles2(getStyles); + const prevStatus = useRef(); + const statusError = status === Status.failed || status === Status.invalid; + const showMessage = + message && + (statusError || + status === Status.changing || + status === Status.deleting || + status === Status.unknown || + status === Status.upgrading); + const [showProgressBar, setShowProgressBar] = useState(getShowProgressBarValue(status, prevStatus.current)); + const statusStyles = useMemo( + () => ({ + [styles.statusActive]: status === Status.ready, + [styles.statusFailed]: statusError, + }), + [status, styles.statusActive, styles.statusFailed, statusError] + ); + const ErrorMessage = useMemo( + () => () =>
    {message ? message.replace(/;/g, '\n') : Messages.dbcluster.table.status.errorMessage}
    , + [message] + ); + const openLogs = () => { + setSelectedCluster(dbCluster); + setLogsModalVisible(true); + }; + + useEffect(() => { + // handles the last step of the progress bar + // creates a delay between the last step and showing active status + // without this the bar would jump from the second last step to active status + if (prevStatus.current === Status.changing && status === Status.ready) { + setTimeout(() => setShowProgressBar(false), COMPLETE_PROGRESS_DELAY); + } else { + setShowProgressBar(getShowProgressBarValue(status, prevStatus.current)); + } + }, [status]); + + useEffect(() => { + prevStatus.current = status; + }); + + return ( +
    + {showProgressBar ? ( + + ) : ( + + {Messages.dbcluster.table.status[status]} + + )} + {showMessage && showProgressBar && ( + + )} +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.types.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.types.ts new file mode 100644 index 0000000000000..5cb0d948445f2 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.types.ts @@ -0,0 +1,7 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface DBClusterStatusProps { + dbCluster: DBCluster; + setSelectedCluster: (dbCluster: DBCluster) => void; + setLogsModalVisible: (isVisible: boolean) => void; +} diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.utils.test.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.utils.test.ts new file mode 100644 index 0000000000000..5e79c7d4b3a08 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.utils.test.ts @@ -0,0 +1,35 @@ +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DBClusterStatus } from '../DBCluster.types'; +import { getProgressMessage, getShowProgressBarValue } from './DBClusterStatus.utils'; + +const { progressError, processing, complete } = Messages.dbcluster.table.status; + +describe('DBClusterStatus.utils::', () => { + it('shows progress bar when status is changing', () => { + expect(getShowProgressBarValue(DBClusterStatus.changing, undefined)).toBeTruthy(); + }); + + it('shows progress bar when status just changed to ready', () => { + expect(getShowProgressBarValue(DBClusterStatus.ready, DBClusterStatus.changing)).toBeTruthy(); + }); + + it("doesn't show progress bar when status is suspended", () => { + expect(getShowProgressBarValue(DBClusterStatus.suspended, DBClusterStatus.changing)).toBeFalsy(); + }); + + it("doesn't show progress bar when status is unknown", () => { + expect(getShowProgressBarValue(DBClusterStatus.unknown, undefined)).toBeFalsy(); + }); + + it('returns correct message when status is changing', () => { + expect(getProgressMessage(DBClusterStatus.changing, undefined)).toEqual(processing); + }); + + it('returns correct message when status just changed to ready', () => { + expect(getProgressMessage(DBClusterStatus.ready, DBClusterStatus.changing)).toEqual(complete); + }); + + it('returns correct message when status is failed', () => { + expect(getProgressMessage(DBClusterStatus.failed, DBClusterStatus.ready)).toEqual(progressError); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.utils.ts b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.utils.ts new file mode 100644 index 0000000000000..c13303313620c --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DBClusterStatus/DBClusterStatus.utils.ts @@ -0,0 +1,31 @@ +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DBClusterStatus } from '../DBCluster.types'; + +const { progressError, processing, complete } = Messages.dbcluster.table.status; + +export const getShowProgressBarValue = (status: DBClusterStatus, previousStatus: DBClusterStatus | undefined) => { + // if the cluster just changed to ready we want to still show the progress bar + if (previousStatus === DBClusterStatus.changing && status === DBClusterStatus.ready) { + return true; + } + + // if the cluster is in any of this status show the progress bar + if (status === DBClusterStatus.changing || status === DBClusterStatus.invalid || status === DBClusterStatus.failed) { + return true; + } + + // if in any of other status (e.g. deleting, suspended, ...) no need to show the progress bar + return false; +}; + +export const getProgressMessage = (status: DBClusterStatus, previousStatus: DBClusterStatus | undefined) => { + if (status === DBClusterStatus.invalid || status === DBClusterStatus.failed) { + return progressError; + } + + if (previousStatus === DBClusterStatus.changing && status === DBClusterStatus.ready) { + return complete; + } + + return processing; +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.styles.ts b/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.styles.ts new file mode 100644 index 0000000000000..79eb3ed11eded --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.styles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme) => ({ + deleteModalContent: css` + margin-bottom: ${theme.spacing.xl}; + `, + namesHighlight: css` + color: ${theme.palette.warn}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.tsx b/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.tsx new file mode 100644 index 0000000000000..781b11c39e45c --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.tsx @@ -0,0 +1,69 @@ +import React, { FC, useCallback } from 'react'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { Modal, logger } from '@percona/platform-core'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { DeleteDBClusterModalProps } from './DeleteDBClusterModal.types'; +import { getStyles } from './DeleteDBClusterModal.styles'; +import { newDBClusterService } from '../DBCluster.utils'; + +export const DeleteDBClusterModal: FC = ({ + isVisible, + setVisible, + setLoading, + onClusterDeleted, + selectedCluster, +}) => { + const styles = useStyles(getStyles); + + const deleteDBCluster = useCallback(async () => { + if (!selectedCluster) { + setVisible(false); + + return; + } + + try { + setLoading(true); + setVisible(false); + const dbClusterService = newDBClusterService(selectedCluster?.databaseType); + + await dbClusterService.deleteDBClusters(selectedCluster); + onClusterDeleted(); + } catch (e) { + setLoading(false); + logger.error(e); + } + }, [selectedCluster, onClusterDeleted, setVisible, setLoading]); + + const ConfirmationMessage = () => + selectedCluster ? ( +

    + Are you sure that you want to delete + {` ${DATABASE_LABELS[selectedCluster.databaseType]} `} + cluster + {` ${selectedCluster.clusterName} `} + from Kubernetes cluster + {` ${selectedCluster.kubernetesClusterName} `}? +

    + ) : null; + + return ( + setVisible(false)}> + + + + + + + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.types.ts b/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.types.ts new file mode 100644 index 0000000000000..b7854d6c4ca49 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/DeleteDBClusterModal/DeleteDBClusterModal.types.ts @@ -0,0 +1,9 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface DeleteDBClusterModalProps { + selectedCluster?: DBCluster; + isVisible: boolean; + setVisible: (value: boolean) => void; + setLoading: (loading: boolean) => void; + onClusterDeleted: () => void; +} diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants.ts b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants.ts new file mode 100644 index 0000000000000..b813eab92d9a5 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants.ts @@ -0,0 +1,37 @@ +import { SelectableValue } from '@grafana/data'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { DBClusterDefaultResources } from '../../AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types'; +import { DBClusterTopology, DBClusterResources } from './DBClusterAdvancedOptions.types'; + +export const TOPOLOGY_OPTIONS: SelectableValue[] = [ + { value: DBClusterTopology.cluster, label: Messages.dbcluster.addModal.topology.cluster }, + { value: DBClusterTopology.single, label: Messages.dbcluster.addModal.topology.single }, +]; + +export const RESOURCES_OPTIONS: SelectableValue[] = [ + { value: DBClusterResources.small, label: Messages.dbcluster.addModal.resources.small }, + { value: DBClusterResources.medium, label: Messages.dbcluster.addModal.resources.medium }, + { value: DBClusterResources.large, label: Messages.dbcluster.addModal.resources.large }, + { value: DBClusterResources.custom, label: Messages.dbcluster.addModal.resources.custom }, +]; + +export const DEFAULT_SIZES: DBClusterDefaultResources = { + small: { + memory: 2, + cpu: 1, + disk: 25, + }, + medium: { + memory: 8, + cpu: 4, + disk: 100, + }, + large: { + memory: 32, + cpu: 8, + disk: 500, + }, +}; + +export const MIN_NODES = 3; +export const MIN_RESOURCES = 0.1; diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.styles.ts b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.styles.ts new file mode 100644 index 0000000000000..bc2a068e47b1e --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.styles.ts @@ -0,0 +1,69 @@ +import { stylesFactory } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = stylesFactory((theme: GrafanaTheme) => { + const { spacing, colors } = theme; + + return { + createButton: css` + margin-top: ${spacing.md}; + margin-right: ${spacing.xl}; + `, + resourcesWrapper: css` + display: flex; + `, + resourcesInputCol: css` + display: flex; + flex-direction: column; + div { + width: 100px; + } + div:not(:last-child) { + margin-bottom: ${spacing.xl}; + } + `, + resourcesBarCol: css` + display: flex; + flex-direction: column; + margin-left: ${spacing.lg}; + width: 50%; + `, + nodesWrapper: css` + margin-bottom: ${spacing.md}; + width: 60px; + div, + label { + white-space: nowrap; + } + `, + resourcesBar: css` + height: 74px; + margin-top: ${spacing.lg}; + margin-bottom: ${spacing.xl}; + `, + resourcesBarEmpty: css` + margin-top: ${spacing.lg}; + margin-bottom: 78px; + `, + resourcesBarLast: css` + margin-bottom: 0; + `, + resourcesInfoWrapper: css` + display: flex; + align-items: center; + background-color: ${colors.bg2}; + padding: ${spacing.sm}; + width: fit-content; + margin-bottom: ${spacing.md}; + `, + resourcesInfoIcon: css` + margin-right: ${spacing.sm}; + `, + resourcesRadioWrapper: css` + align-items: center; + display: flex; + justify-content: space-between; + `, + }; +}); diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.test.tsx b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.test.tsx new file mode 100644 index 0000000000000..3fcced5c1f69a --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.test.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { Form, FormRenderProps } from 'react-final-form'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Databases } from 'app/percona/shared/core'; +import { DBClusterAdvancedOptions } from './DBClusterAdvancedOptions'; +import { EditDBClusterFields } from '../EditDBClusterModal.types'; +import { DBClusterResources } from './DBClusterAdvancedOptions.types'; +import { dbClustersStub } from '../../__mocks__/dbClustersStubs'; + +jest.mock('../../DBCluster.service'); +jest.mock('../../PSMDB.service'); +jest.mock('../../XtraDB.service'); + +jest.mock('@percona/platform-core', () => { + const originalModule = jest.requireActual('@percona/platform-core'); + return { + ...originalModule, + logger: { + error: jest.fn(), + }, + }; +}); + +describe('DBClusterAdvancedOptions::', () => { + it('renders correctly', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + + const radioState = await screen.findByTestId('topology-radio-state'); + + expect(radioState).toBeInTheDocument(); + expect(await screen.queryByTestId('nodes-number-input')).toBeInTheDocument(); + expect(await screen.getAllByTestId('resources-radio-button').length).toBeGreaterThan(0); + expect(await screen.queryByTestId('memory-number-input')).toBeInTheDocument(); + expect(await screen.queryByTestId('cpu-number-input')).toBeInTheDocument(); + expect(await screen.queryByTestId('disk-number-input')).toBeInTheDocument(); + }); + + it('should disable memory, cpu and disk when resources are not custom', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + + const memory = await screen.findByTestId('memory-number-input'); + const cpu = screen.getByTestId('cpu-number-input'); + const disk = screen.getByTestId('disk-number-input'); + + expect(memory).toBeDisabled(); + expect(cpu).toBeDisabled(); + expect(disk).toBeDisabled(); + }); + + it('should enable memory and cpu when resources are custom, disk should be disabled', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + + fireEvent.change(screen.getByTestId('resources-radio-state'), { target: { value: DBClusterResources.custom } }); + + const memory = await screen.findByTestId('memory-number-input'); + const cpu = await screen.getByTestId('cpu-number-input'); + const disk = await screen.getByTestId('disk-number-input'); + + expect(memory).not.toBeDisabled(); + expect(cpu).not.toBeDisabled(); + expect(disk).toBeDisabled(); + }); + + it('should disable button when invalid', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + const button = await screen.findByTestId('dbcluster-update-cluster-button'); + + expect(button).toBeDisabled(); + }); + + it('should enable button when valid', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + const button = await screen.findByTestId('dbcluster-update-cluster-button'); + + expect(button).not.toBeDisabled(); + }); + + it('should disabled single node topology when database is MongoDB', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + const topology = await screen.findAllByTestId('topology-radio-button'); + + expect(topology[1]).toBeDisabled(); + }); + + it('should enable single node topology when database is MySQL', async () => { + act(() => { + render( + ( + + )} + /> + ); + }); + const topology = await screen.findAllByTestId('topology-radio-button'); + + expect(topology[1]).not.toBeDisabled(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.tsx b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.tsx new file mode 100644 index 0000000000000..f7359bce1e8a3 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.tsx @@ -0,0 +1,275 @@ +import React, { FC, useCallback, useState, useMemo, useEffect, useRef } from 'react'; +import { cx } from '@emotion/css'; +import { HorizontalGroup, Icon, useStyles } from '@grafana/ui'; +import { LoaderButton, NumberInputField, RadioButtonGroupField, logger } from '@percona/platform-core'; +import validators from 'app/percona/shared/helpers/validators'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { Databases } from 'app/percona/shared/core'; +import { + TOPOLOGY_OPTIONS, + RESOURCES_OPTIONS, + DEFAULT_SIZES, + MIN_NODES, + MIN_RESOURCES, +} from './DBClusterAdvancedOptions.constants'; +import { getStyles } from './DBClusterAdvancedOptions.styles'; +import { EditDBClusterFields } from '../EditDBClusterModal.types'; +import { DBClusterTopology, DBClusterResources, DBClusterAdvancedOptionsProps } from './DBClusterAdvancedOptions.types'; +import { + canGetExpectedResources, + resourceValidator, +} from '../../AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.utils'; +import { DBClusterAllocatedResources, DBClusterExpectedResources } from '../../DBCluster.types'; +import { DBClusterService } from '../../DBCluster.service'; +import { Overlay } from 'app/percona/shared/components/Elements/Overlay/Overlay'; +import { ResourcesBar } from '../../ResourcesBar/ResourcesBar'; +import { CPU, Disk, Memory } from '../../../DBaaSIcons'; +import { + RECHECK_INTERVAL, + EXPECTED_DELAY, +} from '../../AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants'; +import { getExpectedResourcesDifference, newDBClusterService } from '../../DBCluster.utils'; + +export const DBClusterAdvancedOptions: FC = ({ selectedCluster, renderProps }) => { + const { values, form, valid, pristine, submitting } = renderProps; + const styles = useStyles(getStyles); + const initialExpected = useRef(); + const unmounted = useRef(false); + const [prevResources, setPrevResources] = useState(DBClusterResources.small); + const [customMemory, setCustomMemory] = useState(selectedCluster.memory); + const [customCPU, setCustomCPU] = useState(selectedCluster.cpu); + const [allocatedResources, setAllocatedResources] = useState(); + const [loadingAllocatedResources, setLoadingAllocatedResources] = useState(false); + const [expectedResources, setExpectedResources] = useState(); + const [loadingExpectedResources, setLoadingExpectedResources] = useState(false); + const { required, min } = validators; + const { change } = form; + const nodeValidators = [required, min(MIN_NODES)]; + const resourceValidators = [required, min(MIN_RESOURCES), resourceValidator]; + const { topology, resources, memory, cpu, disk, nodes, single } = values; + const parsePositiveInt = useCallback((value) => (value > 0 && Number.isInteger(+value) ? value : undefined), []); + const resourcesBarStyles = useMemo( + () => ({ + [styles.resourcesBar]: !!allocatedResources, + [styles.resourcesBarEmpty]: !allocatedResources, + }), + [allocatedResources, styles.resourcesBar, styles.resourcesBarEmpty] + ); + const resourcesInputProps = { step: '0.1' }; + let allocatedTimer: NodeJS.Timeout; + let expectedTimer: NodeJS.Timeout; + + const topologies = useMemo( + () => + selectedCluster.databaseType !== Databases.mysql + ? [TOPOLOGY_OPTIONS[0], { ...TOPOLOGY_OPTIONS[1], disabled: true }] + : TOPOLOGY_OPTIONS, + [selectedCluster] + ); + + const getAllocatedResources = async (triggerLoading = true) => { + try { + if (allocatedTimer) { + clearTimeout(allocatedTimer); + } + + if (triggerLoading) { + setLoadingAllocatedResources(true); + } + setAllocatedResources(await DBClusterService.getAllocatedResources(selectedCluster.kubernetesClusterName)); + } catch (e) { + logger.error(e); + } finally { + if (triggerLoading) { + setLoadingAllocatedResources(false); + } + + if (!unmounted.current) { + // eslint-disable-next-line react-hooks/exhaustive-deps + allocatedTimer = setTimeout(() => getAllocatedResources(false), RECHECK_INTERVAL); + } + } + }; + + const getExpectedResources = async () => { + try { + const dbClusterService = newDBClusterService(selectedCluster.databaseType); + + setLoadingExpectedResources(true); + + const expected = await dbClusterService.getExpectedResources({ + clusterName: selectedCluster.clusterName, + kubernetesClusterName: selectedCluster.kubernetesClusterName, + databaseType: selectedCluster.databaseType, + clusterSize: topology === DBClusterTopology.cluster ? nodes : single, + cpu, + memory, + disk, + }); + + // store current cluster expected (consumed) resources + // to use in future expected resources calculations + if (!initialExpected.current) { + initialExpected.current = expected; + } + + setExpectedResources(getExpectedResourcesDifference(expected, initialExpected.current)); + } catch (e) { + logger.error(e); + } finally { + setLoadingExpectedResources(false); + } + }; + + useEffect(() => { + if (prevResources === DBClusterResources.custom) { + setCustomMemory(memory); + setCustomCPU(cpu); + } + + if (resources && resources !== DBClusterResources.custom) { + change(EditDBClusterFields.cpu, DEFAULT_SIZES[resources].cpu); + change(EditDBClusterFields.memory, DEFAULT_SIZES[resources].memory); + } else { + change(EditDBClusterFields.cpu, customCPU); + change(EditDBClusterFields.memory, customMemory); + } + + setPrevResources(resources); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resources]); + + useEffect(() => { + getAllocatedResources(); + + return () => { + clearTimeout(allocatedTimer); + unmounted.current = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (canGetExpectedResources(selectedCluster, values)) { + if (expectedTimer) { + clearTimeout(expectedTimer); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + expectedTimer = setTimeout(() => getExpectedResources(), EXPECTED_DELAY); + } + + return () => clearTimeout(expectedTimer); + }, [memory, cpu, selectedCluster, topology, nodes, single]); + + useEffect(() => { + if (topology === DBClusterTopology.cluster && nodes < MIN_NODES) { + change(EditDBClusterFields.nodes, MIN_NODES); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [topology]); + + return ( + <> + +
    + {topology === DBClusterTopology.single ? ( + + ) : ( + + )} +
    +
    + +
    + + {Messages.dbcluster.addModal.resourcesInfo} +
    +
    +
    +
    + + + +
    +
    + + } + total={allocatedResources?.total.cpu} + allocated={allocatedResources?.allocated.cpu} + expected={expectedResources?.expected.cpu} + className={cx(resourcesBarStyles)} + dataTestId="dbcluster-resources-bar-cpu" + /> + } + total={allocatedResources?.total.memory} + allocated={allocatedResources?.allocated.memory} + expected={expectedResources?.expected.memory} + className={cx(resourcesBarStyles)} + dataTestId="dbcluster-resources-bar-memory" + /> + } + total={allocatedResources?.total.disk} + allocated={allocatedResources?.allocated.disk} + expected={undefined} + className={cx(resourcesBarStyles, styles.resourcesBarLast)} + dataTestId="dbcluster-resources-bar-disk" + /> + +
    +
    + + + {Messages.dbcluster.editModal.confirm} + + + + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types.ts b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types.ts new file mode 100644 index 0000000000000..6f1eeeae5f9e2 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.types.ts @@ -0,0 +1,27 @@ +import { FormRenderProps } from 'react-final-form'; +import { DBCluster } from '../../DBCluster.types'; + +export interface DBClusterAdvancedOptionsProps { + selectedCluster: DBCluster; + renderProps: FormRenderProps; +} + +export enum DBClusterTopology { + cluster = 'cluster', + single = 'single', +} + +export enum DBClusterResources { + small = 'small', + medium = 'medium', + large = 'large', + custom = 'custom', +} + +export interface DBClusterDefaultResources { + [key: string]: { + memory: number; + cpu: number; + disk: number; + }; +} diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.styles.ts b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.styles.ts new file mode 100644 index 0000000000000..a06f6424fcdff --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.styles.ts @@ -0,0 +1,10 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + modalWrapper: css` + div[data-testid='modal-body'] { + width: 50%; + max-width: none; + } + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.test.tsx b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.test.tsx new file mode 100644 index 0000000000000..0ab0abf76c7e4 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { EditDBClusterModal } from './EditDBClusterModal'; +import { setVisibleStub, onDBClusterAddedStub } from './__mocks__/editDBClusterModalStubs'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; +import { render, screen } from '@testing-library/react'; + +jest.mock('app/core/app_events'); +jest.mock('../DBCluster.service'); +jest.mock('../PSMDB.service'); +jest.mock('../XtraDB.service'); + +describe('EditDBClusterModal::', () => { + it('should render advanced options', async () => { + render( + + ); + + expect(await screen.findAllByTestId('resources-radio-button')).toBeTruthy(); + expect(screen.getByTestId('memory-field-container')).toBeInTheDocument(); + expect(screen.getByTestId('cpu-field-container')).toBeInTheDocument(); + expect(screen.getByTestId('disk-field-container')).toBeInTheDocument(); + expect(screen.getByTestId('disk-number-input')).toBeDisabled(); + expect(await screen.findAllByTestId('resources-bar')).toBeTruthy(); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.tsx b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.tsx new file mode 100644 index 0000000000000..5783ceb0b12fc --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.tsx @@ -0,0 +1,100 @@ +import React, { FC, useRef } from 'react'; +import { Modal, logger } from '@percona/platform-core'; +import { Form as FormFinal } from 'react-final-form'; +import { useStyles } from '@grafana/ui'; +import { EditDBClusterModalProps, EditDBClusterRenderProps } from './EditDBClusterModal.types'; +import { DBClusterAdvancedOptions } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions'; +import { DEFAULT_SIZES } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants'; +import { DBClusterResources, DBClusterTopology } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions.types'; +import { DATABASE_LABELS } from 'app/percona/shared/core'; +import { newDBClusterService } from '../DBCluster.utils'; +import { DBCluster } from '../DBCluster.types'; +import { getStyles } from './EditDBClusterModal.styles'; +import { MIN_NODES } from '../AddDBClusterModal/DBClusterAdvancedOptions/DBClusterAdvancedOptions.constants'; + +export const EditDBClusterModal: FC = ({ + isVisible, + setVisible, + onDBClusterChanged, + selectedCluster, +}) => { + const styles = useStyles(getStyles); + const initialValues = useRef(); + const onSubmit = async ({ topology, nodes, single, memory, cpu, disk }: Record) => { + if (!selectedCluster) { + setVisible(false); + + return; + } + + try { + const dbClusterService = newDBClusterService(selectedCluster.databaseType); + + await dbClusterService.updateDBCluster({ + databaseImage: selectedCluster.installedImage, + databaseType: selectedCluster.databaseType, + clusterName: selectedCluster.clusterName, + kubernetesClusterName: selectedCluster.kubernetesClusterName, + clusterSize: topology === DBClusterTopology.cluster ? nodes : single, + cpu, + memory, + disk, + }); + setVisible(false); + onDBClusterChanged(); + } catch (e) { + logger.error(e); + } + }; + + const editModalTitle = `${selectedCluster?.clusterName} ( ${selectedCluster?.databaseType} )`; + + if (!initialValues.current) { + const isCluster = selectedCluster.clusterSize > 1; + const clusterParameters: EditDBClusterRenderProps = { + topology: isCluster ? DBClusterTopology.cluster : DBClusterTopology.single, + nodes: isCluster ? selectedCluster.clusterSize : MIN_NODES, + single: 1, + databaseType: { + value: selectedCluster.databaseType, + label: DATABASE_LABELS[selectedCluster.databaseType], + }, + cpu: selectedCluster.cpu, + disk: selectedCluster.disk, + memory: selectedCluster.memory, + }; + + const isMatchSize = (type: DBClusterResources) => + DEFAULT_SIZES[type].cpu === selectedCluster.cpu && + DEFAULT_SIZES[type].memory === selectedCluster.memory && + DEFAULT_SIZES[type].disk === selectedCluster.disk; + + if (isMatchSize(DBClusterResources.small)) { + clusterParameters.resources = DBClusterResources.small; + } else if (isMatchSize(DBClusterResources.medium)) { + clusterParameters.resources = DBClusterResources.medium; + } else if (isMatchSize(DBClusterResources.large)) { + clusterParameters.resources = DBClusterResources.large; + } else { + clusterParameters.resources = DBClusterResources.custom; + } + + initialValues.current = clusterParameters; + } + + return ( +
    + setVisible(false)}> + ( + + + + )} + /> + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.types.ts b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.types.ts new file mode 100644 index 0000000000000..f6472f3d51114 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/EditDBClusterModal.types.ts @@ -0,0 +1,34 @@ +import { SelectableValue } from '@grafana/data'; +import { DBCluster } from '../DBCluster.types'; +import { DBClusterResources } from './DBClusterAdvancedOptions/DBClusterAdvancedOptions.types'; + +export interface EditDBClusterModalProps { + isVisible: boolean; + setVisible: (value: boolean) => void; + onDBClusterChanged: () => void; + selectedCluster: DBCluster; +} + +export interface EditDBClusterRenderProps { + [EditDBClusterFields.topology]?: string; + [EditDBClusterFields.resources]?: DBClusterResources; + [EditDBClusterFields.nodes]: number; + [EditDBClusterFields.single]: number; + [EditDBClusterFields.databaseType]: SelectableValue; + [EditDBClusterFields.cpu]: number; + [EditDBClusterFields.disk]: number; + [EditDBClusterFields.memory]: number; +} + +export enum EditDBClusterFields { + name = 'name', + kubernetesCluster = 'kubernetesCluster', + databaseType = 'databaseType', + topology = 'topology', + nodes = 'nodes', + single = 'single', + resources = 'resources', + memory = 'memory', + cpu = 'cpu', + disk = 'disk', +} diff --git a/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/__mocks__/editDBClusterModalStubs.ts b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/__mocks__/editDBClusterModalStubs.ts new file mode 100644 index 0000000000000..421cf713ddd6a --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/EditDBClusterModal/__mocks__/editDBClusterModalStubs.ts @@ -0,0 +1,13 @@ +export const kubernetesOptionsStub = [ + { + value: 'Test Cluster 1', + label: 'Test Cluster 1', + }, + { + value: 'Test Cluster 2', + label: 'Test Cluster 2', + }, +]; + +export const setVisibleStub = jest.fn(); +export const onDBClusterAddedStub = jest.fn(); diff --git a/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.styles.ts b/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.styles.ts new file mode 100644 index 0000000000000..cf574541cb355 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.styles.ts @@ -0,0 +1,45 @@ +import { css } from '@emotion/css'; + +export const styles = { + optionWrapper: css` + align-items: center; + display: flex; + justify-content: space-between; + `, + optionText: css` + display: flex; + flex-direction: column; + max-width: 75%; + white-space: normal; + `, + optionTitle: css` + color: #d8d9da; + white-space: nowrap; + `, + optionDescription: css` + color: #8e8e8e; + font-size: 10px; + line-height: 1.5em; + `, + tagWrapper: css` + margin-left: 4px; + `, + tag: css` + background-color: #646464; + border: 1px solid #8a8a8a; + border-radius: 3px; + color: #f2f2f2; + font-size: 11px; + margin-left: 6px; + padding: 2px 4px; + `, + notAvailableTag: css` + background-color: #646464; + border: 1px solid #8a8a8a; + border-radius: 3px; + color: gray; + font-size: 11px; + margin-left: 6px; + padding: 2px 4px; + `, +}; diff --git a/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.test.tsx b/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.test.tsx new file mode 100644 index 0000000000000..508b72ae69b21 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { OptionContent } from './OptionContent'; +import { render } from '@testing-library/react'; + +const title = 'Shared Blocks Written'; +const description = 'Total number of shared blocks written by the statement'; +const tags = ['mysql', 'postgresql']; + +describe('OptionContent::', () => { + it('should render with title, description and tags', () => { + const { container } = render(); + const spans = container.querySelectorAll('div > div > span'); + + expect(spans[0]).toHaveTextContent(title); + expect(spans[1]).toHaveTextContent(description); + expect(spans[2]).toHaveTextContent(tags[0]); + expect(spans[3]).toHaveTextContent(tags[1]); + }); + + it('should render with title, description and one tag', () => { + const { container } = render(); + const spans = container.querySelectorAll('div > div > span'); + + expect(spans[0]).toHaveTextContent(title); + expect(spans[1]).toHaveTextContent(description); + expect(spans[2]).toHaveTextContent(tags[0]); + }); + + it('should render with title, description and empty tags', () => { + const { container } = render(); + const spans = container.querySelectorAll('div > div > span'); + + expect(spans[0]).toHaveTextContent(title); + expect(spans[1]).toHaveTextContent(description); + expect(spans).toHaveLength(2); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.tsx b/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.tsx new file mode 100644 index 0000000000000..581793101d7ff --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/OptionContent/OptionContent.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { styles } from './OptionContent.styles'; + +interface OptionContentProps { + title: string; + description?: string; + tags: string[]; + disabledTags?: string[]; + dataTestId?: string; +} + +export const OptionContent: FC = ({ title, description, tags, disabledTags, dataTestId }) => ( +
    +
    + {title} + {description} +
    +
    + {disabledTags && + disabledTags.map((tag) => ( + + {tag} + + ))} + {tags.map((tag) => ( + + {tag} + + ))} +
    +
    +); diff --git a/public/app/percona/dbaas/components/DBCluster/PSMDB.service.ts b/public/app/percona/dbaas/components/DBCluster/PSMDB.service.ts new file mode 100644 index 0000000000000..62f88ea138a26 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/PSMDB.service.ts @@ -0,0 +1,174 @@ +import { omit, pick } from 'lodash'; +import { Databases } from 'app/percona/shared/core'; +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { + DatabaseVersion, + CpuUnits, + DBCluster, + DBClusterActionAPI, + DBClusterComponents, + DBClusterConnectionAPI, + DBClusterExpectedResources, + DBClusterExpectedResourcesAPI, + DBClusterPayload, + ResourcesUnits, + DBClusterComponent, + DBClusterChangeComponentsAPI, + DBClusterType, + DBClusterStatus, + DBClusterSuspendResumeRequest, +} from './DBCluster.types'; +import { DBClusterService } from './DBCluster.service'; +import { BILLION, THOUSAND } from './DBCluster.constants'; +import { + ManageComponentsVersionsRenderProps, + SupportedComponents, +} from '../Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types'; +import { getComponentChange } from './DBCluster.service.utils'; +import { Operators } from './AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; + +export class PSMDBService extends DBClusterService { + addDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post('/DBaaS/PSMDBCluster/Create', toAPI(dbCluster)); + } + + updateDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post('/DBaaS/PSMDBCluster/Update', toAPI(dbCluster)); + } + + resumeDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/PSMDBCluster/Update', + toResumeAPI(dbCluster) + ); + } + + suspendDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/PSMDBCluster/Update', + toSuspendAPI(dbCluster) + ); + } + + deleteDBClusters(dbCluster: DBCluster): Promise { + const body = { + name: dbCluster.clusterName, + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + cluster_type: DBClusterType.psmdb, + }; + + return apiManagement.post('/DBaaS/DBClusters/Delete', body); + } + + getDBClusterCredentials(dbCluster: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/PSMDBClusters/GetCredentials', + omit(toAPI(dbCluster), ['params']) + ); + } + + restartDBCluster(dbCluster: DBCluster): Promise { + const body = { + name: dbCluster.clusterName, + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + cluster_type: DBClusterType.psmdb, + }; + + return apiManagement.post('/DBaaS/DBClusters/Restart', body); + } + + getComponents(kubernetesClusterName: string): Promise { + return apiManagement.post('/DBaaS/Components/GetPSMDB', { + kubernetes_cluster_name: kubernetesClusterName, + }); + } + + setComponents(kubernetesClusterName: string, componentsVersions: ManageComponentsVersionsRenderProps): Promise { + return apiManagement.post('/DBaaS/Components/ChangePSMDB', { + kubernetes_cluster_name: kubernetesClusterName, + mongod: getComponentChange(Operators.psmdb, SupportedComponents.mongod, componentsVersions), + }); + } + + getDatabaseVersions(kubernetesClusterName: string): Promise { + return this.getComponents(kubernetesClusterName).then(({ versions }) => { + return Object.entries(versions[0].matrix.mongod as DBClusterComponent).map(([version, component]) => ({ + value: component.image_path, + label: version, + default: !!component.default, + disabled: !!component.disabled, + })); + }); + } + + getExpectedResources(dbCluster: DBCluster): Promise { + return apiManagement + .post>( + '/DBaaS/PSMDBCluster/Resources/Get', + pick(toAPI(dbCluster), ['params']) + ) + .then(({ expected }: DBClusterExpectedResourcesAPI) => ({ + expected: { + cpu: { value: expected.cpu_m / THOUSAND, units: CpuUnits.MILLI, original: +expected.cpu_m }, + memory: { + value: expected.memory_bytes / BILLION, + units: ResourcesUnits.GB, + original: +expected.memory_bytes, + }, + disk: { value: expected.disk_size / BILLION, units: ResourcesUnits.GB, original: +expected.disk_size }, + }, + })); + } + + toModel(dbCluster: DBClusterPayload, kubernetesClusterName: string, databaseType: Databases): DBCluster { + return { + clusterName: dbCluster.name, + kubernetesClusterName, + databaseType, + clusterSize: dbCluster.params.cluster_size, + memory: (dbCluster.params.replicaset?.compute_resources?.memory_bytes || 0) / BILLION, + cpu: (dbCluster.params.replicaset?.compute_resources?.cpu_m || 0) / THOUSAND, + disk: (dbCluster.params.replicaset?.disk_size || 0) / BILLION, + status: dbCluster.state || DBClusterStatus.changing, + message: dbCluster.operation?.message, + finishedSteps: dbCluster.operation?.finished_steps || 0, + totalSteps: dbCluster.operation?.total_steps || 0, + expose: dbCluster.exposed, + installedImage: dbCluster.installed_image, + availableImage: dbCluster.available_image, + }; + } +} + +const toAPI = (dbCluster: DBCluster) => ({ + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + name: dbCluster.clusterName, + expose: dbCluster.expose, + params: { + cluster_size: dbCluster.clusterSize, + replicaset: { + compute_resources: { + cpu_m: dbCluster.cpu * THOUSAND, + memory_bytes: dbCluster.memory * BILLION, + }, + disk_size: dbCluster.disk * BILLION, + }, + image: dbCluster.databaseImage, + }, +}); + +const toSuspendAPI = (dbCluster: DBCluster) => ({ + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + name: dbCluster.clusterName, + params: { + suspend: true, + }, +}); + +const toResumeAPI = (dbCluster: DBCluster) => ({ + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + name: dbCluster.clusterName, + params: { + resume: true, + }, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.messages.ts b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.messages.ts new file mode 100644 index 0000000000000..e364f99ac6c9e --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.messages.ts @@ -0,0 +1,13 @@ +import { ResourcesWithUnits } from '../DBCluster.types'; + +export const Messages = { + buildResourcesLabel: (allocated: ResourcesWithUnits, allocatedWidth: number, total: ResourcesWithUnits) => + `Using ${allocated.value} ${allocated.units} (${allocatedWidth}%) of ${total.value} ${total.units} in total`, + buildExpectedLabel: (expected: ResourcesWithUnits, resourceLabel: string) => + `Required ${resourceLabel} (${expected.value} ${expected.units})`, + buildExpectedAllocatedLabel: (expectedDowsize: ResourcesWithUnits, resourceLabel: string) => + `Expected Consumed ${resourceLabel} (${expectedDowsize.value} ${expectedDowsize.units})`, + buildAllocatedLabel: (resourceLabel: string) => `Consumed ${resourceLabel}`, + buildInsufficientLabel: (expected: ResourcesWithUnits, resourceLabel: string) => + `Insufficient ${resourceLabel} (${expected.value} ${expected.units} required)`, +}; diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.styles.ts b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.styles.ts new file mode 100644 index 0000000000000..648e76c10814c --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.styles.ts @@ -0,0 +1,79 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +const expectedColor = '#043464'; + +export const getStyles = ({ colors, palette, spacing, typography }: GrafanaTheme) => ({ + resourcesBarWrapper: css` + display: flex; + flex-direction: row; + `, + iconWrapper: css` + margin-right: ${spacing.sm}; + `, + resourcesBarContent: css` + display: flex; + flex: 1; + flex-direction: column; + `, + resourcesBarBackground: css` + background-color: ${palette.gray1}; + border-radius: 50px; + height: ${spacing.sm}; + position: relative; + width: 100%; + `, + resourcesBarLabel: css` + color: ${colors.textSemiWeak}; + font-size: ${typography.size.sm}; + margin-top: ${spacing.xs}; + `, + captionWrapper: css` + align-items: center; + display: flex; + margin-top: ${spacing.xs}; + max-height: ${typography.size.lg}; + `, + captionSquare: css` + width: 10px; + height: 10px; + `, + allocatedSquare: css` + background-color: ${palette.blue80}; + `, + expectedSquare: css` + background-color: ${expectedColor}; + `, + expectedAllocatedSquare: css` + background-color: ${palette.greenBase}; + `, + captionLabel: css` + color: ${colors.textSemiWeak}; + font-size: ${typography.size.sm}; + margin-left: ${spacing.sm}; + `, + filled: css` + border-radius: inherit; + height: 100%; + position: absolute; + transition: width 300ms ease-in-out; + `, + filledAllocated: css` + background-color: ${palette.blue80}; + `, + filledExpected: css` + background-color: ${expectedColor}; + `, + filledExpectedAllocated: css` + background-color: ${palette.greenBase}; + `, + filledInsufficient: css` + background-color: ${palette.brandDanger}; + `, + insufficientIcon: css` + color: ${palette.brandDanger}; + `, + getFilledStyles: (width: number) => css` + width: ${width}%; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.test.tsx b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.test.tsx new file mode 100644 index 0000000000000..fb53cccc7b1dd --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { ResourcesBar } from './ResourcesBar'; +import { Messages } from './ResourcesBar.messages'; +import { ResourcesUnits } from '../DBCluster.types'; +import { render, screen } from '@testing-library/react'; + +describe('ResourcesBar::', () => { + it('renders correctly with icon, allocated, expected and label', () => { + const allocated = { value: 2, units: ResourcesUnits.GB, original: 2 }; + const total = { value: 10, units: ResourcesUnits.GB, original: 10 }; + const expected = { value: 2, units: ResourcesUnits.GB, original: 2 }; + const resourceLabel = 'Memory'; + render( + Test icon
    } + allocated={allocated} + expected={expected} + total={total} + resourceLabel={resourceLabel} + /> + ); + + expect(screen.getByTestId('resources-bar-icon')).toHaveTextContent('Test icon'); + expect(screen.getByTestId('resources-bar-label')).toHaveTextContent( + Messages.buildResourcesLabel(allocated, 20, total) + ); + expect(screen.getByTestId('resources-bar-allocated-caption')).toHaveTextContent( + Messages.buildAllocatedLabel(resourceLabel) + ); + expect(screen.getByTestId('resources-bar-expected-caption')).toHaveTextContent( + Messages.buildExpectedLabel(expected, resourceLabel) + ); + }); + it('renders invalid message for insufficient resources', () => { + const allocated = { value: 2, units: ResourcesUnits.GB, original: 2 }; + const total = { value: 10, units: ResourcesUnits.GB, original: 10 }; + const expected = { value: 20, units: ResourcesUnits.GB, original: 20 }; + const resourceLabel = 'Memory'; + render(); + + expect(screen.getByTestId('resources-bar-insufficient-resources')).toHaveTextContent( + Messages.buildInsufficientLabel(expected, resourceLabel) + ); + }); + it('renders correctly when expected value is negative', () => { + const allocated = { value: 4, units: ResourcesUnits.GB, original: 4 }; + const total = { value: 10, units: ResourcesUnits.GB, original: 10 }; + const expected = { value: -2, units: ResourcesUnits.GB, original: -2 }; + render( + Test icon} + allocated={allocated} + expected={expected} + total={total} + resourceLabel="Test label" + /> + ); + + const resourcesBar = screen.getByTestId('resources-bar'); + + expect(resourcesBar.children).toHaveLength(1); + expect(resourcesBar.children[0].children).toHaveLength(1); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.tsx b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.tsx new file mode 100644 index 0000000000000..51a8dba32eff1 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.tsx @@ -0,0 +1,98 @@ +import React, { FC } from 'react'; +import { cx } from '@emotion/css'; +import { Icon, useStyles } from '@grafana/ui'; +import { getStyles } from './ResourcesBar.styles'; +import { ResourcesBarProps } from './ResourcesBar.types'; +import { + formatResources, + getExpectedAllocated, + getExpectedAllocatedWidth, + getResourcesWidth, +} from './ResourcesBar.utils'; +import { Messages } from './ResourcesBar.messages'; + +export const ResourcesBar: FC = ({ + total, + allocated, + expected, + resourceLabel, + icon, + dataTestId, + className, +}) => { + const styles = useStyles(getStyles); + const requiredResources = allocated && expected ? allocated.original + expected.original : undefined; + const allocatedWidth = getResourcesWidth(allocated?.original, total?.original); + const expectedWidth = getResourcesWidth(requiredResources, total?.original); + const expectedAllocatedWidth = getExpectedAllocatedWidth(expected, allocated); + const expectedAllocated = getExpectedAllocated(expected, allocated); + const isDownsize = expected && expected.value < 0; + const isResourceInsufficient = requiredResources && total ? requiredResources > total.original : false; + const expectedSquareStyles = { + [styles.expectedSquare]: !isDownsize, + [styles.expectedAllocatedSquare]: isDownsize, + }; + + return ( +
    +
    + {icon} +
    +
    +
    + {isResourceInsufficient ? ( +
    + ) : ( + !isDownsize && ( +
    + ) + )} + {allocated && ( +
    + {isDownsize && ( +
    + )} +
    + )} +
    + {allocated && total && ( + + {Messages.buildResourcesLabel(formatResources(allocated), allocatedWidth, formatResources(total))} + + )} + {allocated && ( +
    +
    + + {Messages.buildAllocatedLabel(resourceLabel)} + +
    + )} + {expected && expected.value !== 0 && !isResourceInsufficient && ( +
    +
    + + {isDownsize + ? Messages.buildExpectedAllocatedLabel(formatResources(expectedAllocated), resourceLabel) + : Messages.buildExpectedLabel(formatResources(expected), resourceLabel)} + +
    + )} + {expected && isResourceInsufficient && ( +
    + + + {Messages.buildInsufficientLabel(formatResources(expected), resourceLabel)} + +
    + )} +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.types.ts b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.types.ts new file mode 100644 index 0000000000000..9fe576bf37b6f --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.types.ts @@ -0,0 +1,12 @@ +import { ReactNode } from 'react'; +import { ResourcesWithUnits } from '../DBCluster.types'; + +export interface ResourcesBarProps { + total: ResourcesWithUnits | undefined; + allocated: ResourcesWithUnits | undefined; + expected: ResourcesWithUnits | undefined; + resourceLabel: string; + icon?: ReactNode; + dataTestId?: string; + className?: string; +} diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.test.ts b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.test.ts new file mode 100644 index 0000000000000..6fa12db63971c --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.test.ts @@ -0,0 +1,58 @@ +import { ResourcesUnits, ResourcesWithUnits } from '../DBCluster.types'; +import { resourcesA, resourcesB } from '../__mocks__/dbClustersStubs'; +import { + formatResources, + getExpectedAllocated, + getExpectedAllocatedWidth, + getResourcesWidth, +} from './ResourcesBar.utils'; + +describe('ResourcesBar.utils::', () => { + it('returns correct width', () => { + expect(getResourcesWidth(1.5, 6)).toEqual(25); + expect(getResourcesWidth(1.6, 6)).toEqual(26.7); + expect(getResourcesWidth(5.6, 64)).toEqual(8.8); + expect(getResourcesWidth(63.8, 64)).toEqual(99.7); + expect(getResourcesWidth(10, 80)).toEqual(12.5); + expect(getResourcesWidth(20, 80)).toEqual(25); + }); + + it('formats resources to 2 decimal places if needed', () => { + const getValueWithUnits = (value: number) => ({ value, units: ResourcesUnits.GB } as ResourcesWithUnits); + + expect(formatResources(getValueWithUnits(0.04))).toEqual(getValueWithUnits(0.04)); + expect(formatResources(getValueWithUnits(0.004))).toEqual(getValueWithUnits(0)); + expect(formatResources(getValueWithUnits(0.07340032))).toEqual(getValueWithUnits(0.07)); + expect(formatResources(getValueWithUnits(0.076))).toEqual(getValueWithUnits(0.08)); + expect(formatResources(getValueWithUnits(4.129873))).toEqual(getValueWithUnits(4.13)); + expect(formatResources(getValueWithUnits(0.65))).toEqual(getValueWithUnits(0.65)); + expect(formatResources(getValueWithUnits(6))).toEqual(getValueWithUnits(6)); + }); + + it('returns correct expected allocated width', () => { + expect(getExpectedAllocatedWidth(undefined, undefined)).toBe(0); + expect(getExpectedAllocatedWidth(resourcesB, resourcesA)).toBe(0); + expect(getExpectedAllocatedWidth({ ...resourcesA, original: -40 }, resourcesB)).toBe(0); + expect(getExpectedAllocatedWidth(resourcesA, resourcesB)).toBe(50); + expect(getExpectedAllocatedWidth({ ...resourcesA, original: -5 }, resourcesB)).toBe(75); + expect(getExpectedAllocatedWidth({ ...resourcesA, original: -1 }, { ...resourcesA, original: 4 })).toBe(75); + }); + + it('returns correct expected allocated value', () => { + expect(getExpectedAllocated(undefined, undefined)).toEqual({ + value: 0, + original: 0, + units: ResourcesUnits.BYTES, + }); + expect(getExpectedAllocated(resourcesA, resourcesA)).toEqual({ + value: 20, + original: 20, + units: ResourcesUnits.BYTES, + }); + expect(getExpectedAllocated(resourcesA, resourcesB)).toEqual({ + value: 30, + original: 30, + units: ResourcesUnits.BYTES, + }); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.ts b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.ts new file mode 100644 index 0000000000000..0c9dd9a368515 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/ResourcesBar/ResourcesBar.utils.ts @@ -0,0 +1,34 @@ +import { ResourcesUnits, ResourcesWithUnits } from '../DBCluster.types'; +import { getResourcesSum } from '../DBCluster.utils'; + +export const getResourcesWidth = (allocated?: number, total?: number) => { + if (!allocated || !total || total <= 0) { + return 0; + } + + return Math.round(((allocated * 100) / total) * 10) / 10; +}; + +export const formatResources = (resource: ResourcesWithUnits) => ({ + ...resource, + value: Math.round(resource.value * 100 + Number.EPSILON) / 100, +}); + +export const getExpectedAllocatedWidth = (expected?: ResourcesWithUnits, allocated?: ResourcesWithUnits) => { + if (!expected || !allocated || Math.abs(expected.original) > allocated.original) { + return 0; + } + + return 100 - Math.abs(getResourcesWidth(expected.original, allocated.original)); +}; + +export const getExpectedAllocated = ( + expected?: ResourcesWithUnits, + allocated?: ResourcesWithUnits +): ResourcesWithUnits => { + if (!expected || !allocated || Math.abs(expected.original) > allocated.original) { + return { value: 0, original: 0, units: expected?.units || ResourcesUnits.BYTES }; + } + + return getResourcesSum(expected, allocated) as ResourcesWithUnits; +}; diff --git a/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.messages.tsx b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.messages.tsx new file mode 100644 index 0000000000000..65792fd7503bf --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.messages.tsx @@ -0,0 +1,21 @@ +/* eslint-disable react/display-name */ +import React, { ReactNode } from 'react'; +import { DATABASE_LABELS } from 'app/percona/shared/core/constants'; +import { Databases } from 'app/percona/shared/core'; + +export const Messages = { + cancel: 'Cancel', + confirm: 'Update', + title: 'Confirm database update', + buildUpdateDatabaseMessage: ( + databaseType: Databases, + installedVersion: ReactNode, + availableVersion: ReactNode, + clusterName: ReactNode + ) => ( + <> + Are you sure you want to update {DATABASE_LABELS[databaseType]} {installedVersion} to version {availableVersion}{' '} + in {clusterName} cluster? + + ), +}; diff --git a/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.styles.ts b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.styles.ts new file mode 100644 index 0000000000000..50771177a2f1d --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ palette, spacing }: GrafanaTheme) => ({ + modalWrapper: css` + div[data-testid='modal-body'] { + max-width: none; + } + `, + updateModalContent: css` + margin-bottom: ${spacing.xl}; + `, + highlight: css` + color: ${palette.warn}; + `, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.test.tsx b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.test.tsx new file mode 100644 index 0000000000000..da3e2e1bc0f1e --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { dataTestId } from '@percona/platform-core'; +import { UpdateDBClusterModal } from './UpdateDBClusterModal'; +import { dbClustersStub } from '../__mocks__/dbClustersStubs'; + +jest.mock('../XtraDB.service'); + +describe('UpdateDBClusterModal::', () => { + it('should render message with new database version', () => { + const { container } = render( + + ); + const message = 'MySQL 5.6 to version 8.0 in dbcluster1'; + + expect(container.querySelector(dataTestId('update-dbcluster-message'))?.textContent).toContain(message); + }); + + it('should call onUpdateFinished after update', async () => { + const onUpdateFinished = jest.fn(); + const { container } = render( + + ); + + const button = container.querySelector(dataTestId('confirm-update-dbcluster-button')); + + fireEvent.click(button!); + + await waitFor(() => expect(onUpdateFinished).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.tsx b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.tsx new file mode 100644 index 0000000000000..c33b494e66460 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.tsx @@ -0,0 +1,60 @@ +import React, { FC, useCallback } from 'react'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { Modal, logger } from '@percona/platform-core'; +import { Messages } from './UpdateDBClusterModal.messages'; +import { UpdateDBClusterModalProps } from './UpdateDBClusterModal.types'; +import { getStyles } from './UpdateDBClusterModal.styles'; +import { formatDBClusterVersionWithBuild, newDBClusterService } from '../DBCluster.utils'; + +const { title, confirm, cancel, buildUpdateDatabaseMessage } = Messages; + +export const UpdateDBClusterModal: FC = ({ + dbCluster, + isVisible, + setVisible, + setLoading, + onUpdateFinished, +}) => { + const styles = useStyles(getStyles); + const { clusterName, databaseType, installedImage, availableImage } = dbCluster; + + const onClose = useCallback(() => setVisible(false), [setVisible]); + + const update = useCallback(async () => { + try { + setLoading(true); + onClose(); + const dbClusterService = newDBClusterService(dbCluster?.databaseType); + + await dbClusterService.updateDBCluster({ ...dbCluster, databaseImage: availableImage }); + onUpdateFinished(); + } catch (e) { + setLoading(false); + logger.error(e); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbCluster, onUpdateFinished]); + + return ( +
    + +

    + {buildUpdateDatabaseMessage( + databaseType, + {formatDBClusterVersionWithBuild(installedImage)}, + {formatDBClusterVersionWithBuild(availableImage)}, + {clusterName} + )} +

    + + + + +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.types.ts b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.types.ts new file mode 100644 index 0000000000000..c5be879b73b56 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/UpdateDBClusterModal/UpdateDBClusterModal.types.ts @@ -0,0 +1,9 @@ +import { DBCluster } from '../DBCluster.types'; + +export interface UpdateDBClusterModalProps { + dbCluster: DBCluster; + isVisible: boolean; + setVisible: (value: boolean) => void; + setLoading: (loading: boolean) => void; + onUpdateFinished: () => void; +} diff --git a/public/app/percona/dbaas/components/DBCluster/XtraDB.service.ts b/public/app/percona/dbaas/components/DBCluster/XtraDB.service.ts new file mode 100644 index 0000000000000..cf52bcbb8d117 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/XtraDB.service.ts @@ -0,0 +1,182 @@ +import { omit, pick } from 'lodash'; +import { Databases } from 'app/percona/shared/core'; +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { + DatabaseVersion, + CpuUnits, + DBCluster, + DBClusterActionAPI, + DBClusterComponents, + DBClusterConnectionAPI, + DBClusterExpectedResources, + DBClusterExpectedResourcesAPI, + DBClusterPayload, + ResourcesUnits, + DBClusterComponent, + DBClusterChangeComponentsAPI, + DBClusterType, + DBClusterStatus, + DBClusterSuspendResumeRequest, +} from './DBCluster.types'; +import { DBClusterService } from './DBCluster.service'; +import { BILLION, THOUSAND } from './DBCluster.constants'; +import { + ManageComponentsVersionsRenderProps, + SupportedComponents, +} from '../Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types'; +import { getComponentChange } from './DBCluster.service.utils'; +import { Operators } from './AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; + +export class XtraDBService extends DBClusterService { + addDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post('/DBaaS/PXCCluster/Create', toAPI(dbCluster)); + } + + updateDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post('/DBaaS/PXCCluster/Update', toAPI(dbCluster)); + } + + resumeDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/PXCCluster/Update', + toResumeAPI(dbCluster) + ); + } + + suspendDBCluster(dbCluster: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/PXCCluster/Update', + toSuspendAPI(dbCluster) + ); + } + + deleteDBClusters(dbCluster: DBCluster): Promise { + const body = { + name: dbCluster.clusterName, + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + cluster_type: DBClusterType.pxc, + }; + + return apiManagement.post('/DBaaS/DBClusters/Delete', body); + } + + getDBClusterCredentials(dbCluster: DBCluster): Promise { + return apiManagement.post( + '/DBaaS/PXCClusters/GetCredentials', + omit(toAPI(dbCluster), ['params']) + ); + } + + restartDBCluster(dbCluster: DBCluster): Promise { + const body = { + name: dbCluster.clusterName, + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + cluster_type: DBClusterType.pxc, + }; + + return apiManagement.post('/DBaaS/DBClusters/Restart', body); + } + + getComponents(kubernetesClusterName: string): Promise { + return apiManagement.post('/DBaaS/Components/GetPXC', { + kubernetes_cluster_name: kubernetesClusterName, + }); + } + + setComponents(kubernetesClusterName: string, componentsVersions: ManageComponentsVersionsRenderProps): Promise { + return apiManagement.post('/DBaaS/Components/ChangePXC', { + kubernetes_cluster_name: kubernetesClusterName, + pxc: getComponentChange(Operators.pxc, SupportedComponents.pxc, componentsVersions), + haproxy: getComponentChange(Operators.pxc, SupportedComponents.haproxy, componentsVersions), + }); + } + + getDatabaseVersions(kubernetesClusterName: string): Promise { + return this.getComponents(kubernetesClusterName).then(({ versions }) => { + return Object.entries(versions[0].matrix.pxc as DBClusterComponent).map(([version, component]) => ({ + value: component.image_path, + label: version, + default: !!component.default, + disabled: !!component.disabled, + })); + }); + } + + getExpectedResources(dbCluster: DBCluster): Promise { + return apiManagement + .post>( + '/DBaaS/PXCCluster/Resources/Get', + pick(toAPI(dbCluster), ['params']) + ) + .then(({ expected }: DBClusterExpectedResourcesAPI) => ({ + expected: { + cpu: { value: expected.cpu_m / THOUSAND, units: CpuUnits.MILLI, original: +expected.cpu_m }, + memory: { + value: expected.memory_bytes / BILLION, + units: ResourcesUnits.GB, + original: +expected.memory_bytes, + }, + disk: { value: expected.disk_size / BILLION, units: ResourcesUnits.GB, original: +expected.disk_size }, + }, + })); + } + + toModel(dbCluster: DBClusterPayload, kubernetesClusterName: string, databaseType: Databases): DBCluster { + return { + clusterName: dbCluster.name, + kubernetesClusterName, + databaseType, + clusterSize: dbCluster.params.cluster_size, + memory: (dbCluster.params.pxc?.compute_resources?.memory_bytes || 0) / BILLION, + cpu: (dbCluster.params.pxc?.compute_resources?.cpu_m || 0) / THOUSAND, + disk: (dbCluster.params.pxc?.disk_size || 0) / BILLION, + status: dbCluster.state || DBClusterStatus.changing, + message: dbCluster.operation?.message, + finishedSteps: dbCluster.operation?.finished_steps || 0, + totalSteps: dbCluster.operation?.total_steps || 0, + expose: dbCluster.exposed, + installedImage: dbCluster.installed_image, + availableImage: dbCluster.available_image, + }; + } +} + +const toAPI = (dbCluster: DBCluster): DBClusterPayload => ({ + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + name: dbCluster.clusterName, + expose: dbCluster.expose, + params: { + cluster_size: dbCluster.clusterSize, + pxc: { + compute_resources: { + cpu_m: dbCluster.cpu * THOUSAND, + memory_bytes: dbCluster.memory * BILLION, + }, + disk_size: dbCluster.disk * BILLION, + image: dbCluster.databaseImage, + }, + // Temporary mock data + haproxy: { + compute_resources: { + cpu_m: THOUSAND, + memory_bytes: 2 * BILLION, + }, + }, + }, +}); + +const toSuspendAPI = (dbCluster: DBCluster) => ({ + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + name: dbCluster.clusterName, + params: { + suspend: true, + }, +}); + +const toResumeAPI = (dbCluster: DBCluster) => ({ + kubernetes_cluster_name: dbCluster.kubernetesClusterName, + name: dbCluster.clusterName, + params: { + resume: true, + }, +}); diff --git a/public/app/percona/dbaas/components/DBCluster/__mocks__/DBCluster.hooks.ts b/public/app/percona/dbaas/components/DBCluster/__mocks__/DBCluster.hooks.ts new file mode 100644 index 0000000000000..59a3c54c6b153 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/__mocks__/DBCluster.hooks.ts @@ -0,0 +1,13 @@ +import { Kubernetes } from 'app/percona/dbaas/components/Kubernetes/Kubernetes.types'; +import { dbClustersStub, getDBClustersActionStub } from './dbClustersStubs'; +import { DBCluster, ManageDBClusters } from '../DBCluster.types'; + +export const useDBClusters = (kubernetes: Kubernetes[]): ManageDBClusters => { + const dbClusters: DBCluster[] = []; + + if (kubernetes.length > 0) { + dbClusters.push(...dbClustersStub); + } + + return [dbClusters, getDBClustersActionStub, () => {}, false]; +}; diff --git a/public/app/percona/dbaas/components/DBCluster/__mocks__/DBCluster.service.ts b/public/app/percona/dbaas/components/DBCluster/__mocks__/DBCluster.service.ts new file mode 100644 index 0000000000000..0c57e84aeadee --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/__mocks__/DBCluster.service.ts @@ -0,0 +1,12 @@ +import { DBClusterAllocatedResources } from '../DBCluster.types'; +import { dbCLusterAllocatedResourcesStub, dbClusterLogsAPI } from './dbClustersStubs'; + +export class DBClusterService { + static async getLogs() { + return dbClusterLogsAPI; + } + + static async getAllocatedResources(): Promise { + return Promise.resolve(dbCLusterAllocatedResourcesStub); + } +} diff --git a/public/app/percona/dbaas/components/DBCluster/__mocks__/PSMDB.service.ts b/public/app/percona/dbaas/components/DBCluster/__mocks__/PSMDB.service.ts new file mode 100644 index 0000000000000..64a5b5db475fb --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/__mocks__/PSMDB.service.ts @@ -0,0 +1,25 @@ +import { + dbClusterExpectedResourcesStub, + mongoDBClusterConnectionStub, + psmdbComponentsVersionsStubs, +} from './dbClustersStubs'; + +export class PSMDBService { + getDBClusterCredentials() { + return { connection_credentials: mongoDBClusterConnectionStub }; + } + + restartDBCluster() {} + + getExpectedResources() { + return Promise.resolve(dbClusterExpectedResourcesStub); + } + + getComponents() { + return Promise.resolve(psmdbComponentsVersionsStubs); + } + + updateDBCluster() { + return Promise.resolve(); + } +} diff --git a/public/app/percona/dbaas/components/DBCluster/__mocks__/XtraDB.service.ts b/public/app/percona/dbaas/components/DBCluster/__mocks__/XtraDB.service.ts new file mode 100644 index 0000000000000..2001ecd85a577 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/__mocks__/XtraDB.service.ts @@ -0,0 +1,25 @@ +import { + dbClusterExpectedResourcesStub, + xtraDBClusterConnectionStub, + xtradbComponentsVersionsStubs, +} from './dbClustersStubs'; + +export class XtraDBService { + getDBClusterCredentials() { + return { connection_credentials: xtraDBClusterConnectionStub }; + } + + restartDBCluster() {} + + getExpectedResources() { + return Promise.resolve(dbClusterExpectedResourcesStub); + } + + getComponents() { + return Promise.resolve(xtradbComponentsVersionsStubs); + } + + updateDBCluster() { + return Promise.resolve(); + } +} diff --git a/public/app/percona/dbaas/components/DBCluster/__mocks__/dbClustersStubs.ts b/public/app/percona/dbaas/components/DBCluster/__mocks__/dbClustersStubs.ts new file mode 100644 index 0000000000000..df028512a6b27 --- /dev/null +++ b/public/app/percona/dbaas/components/DBCluster/__mocks__/dbClustersStubs.ts @@ -0,0 +1,212 @@ +import { Databases } from 'app/percona/shared/core'; +import { Operators } from '../AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { + CpuUnits, + DBCluster, + DBClusterConnection, + DBClusterStatus, + ResourcesUnits, + DBClusterComponentVersionStatus, + DBClusterAllocatedResources, + ResourcesWithUnits, +} from '../DBCluster.types'; + +export const dbClustersStub: DBCluster[] = [ + { + kubernetesClusterName: 'Kubernetes Cluster 1', + clusterName: 'dbcluster1', + databaseType: Databases.mysql, + clusterSize: 3, + memory: 1024, + cpu: 1, + disk: 25, + status: DBClusterStatus.ready, + finishedSteps: 5, + totalSteps: 10, + expose: true, + installedImage: 'percona/percona-xtra-dbcluster:5.6', + availableImage: 'percona/percona-xtra-dbcluster:8.0', + }, + { + kubernetesClusterName: 'Kubernetes Cluster 2', + clusterName: 'dbcluster2', + databaseType: Databases.mysql, + clusterSize: 7, + memory: 2048, + cpu: 4, + disk: 25, + finishedSteps: 7, + totalSteps: 7, + expose: false, + installedImage: 'percona/percona-xtra-dbcluster:8.0', + }, + { + kubernetesClusterName: 'Kubernetes Cluster 1', + clusterName: 'mongodbcluster1', + databaseType: Databases.mongodb, + clusterSize: 3, + memory: 0, + cpu: 0, + disk: 25, + status: DBClusterStatus.ready, + finishedSteps: 1, + totalSteps: 2, + expose: false, + }, + { + kubernetesClusterName: 'Kubernetes Cluster 2', + clusterName: 'dbcluster3', + databaseType: Databases.mysql, + clusterSize: 7, + memory: 2048, + cpu: 4, + disk: 25, + status: DBClusterStatus.failed, + message: 'Cluster creation failed', + finishedSteps: 0, + totalSteps: 2, + expose: false, + }, + { + kubernetesClusterName: 'Kubernetes Cluster 1', + clusterName: 'dbcluster4', + databaseType: Databases.mysql, + clusterSize: 3, + memory: 1024, + cpu: 1, + disk: 25, + status: DBClusterStatus.failed, + finishedSteps: 5, + totalSteps: 10, + }, +]; + +export const xtraDBClusterConnectionStub: DBClusterConnection = { + host: 'dbcluster-haproxy', + password: '1234', + port: 3000, + username: 'root', +}; + +export const mongoDBClusterConnectionStub: DBClusterConnection = { + host: 'dbcluster-psmdb', + password: '1234', + port: 3000, + username: 'root', +}; + +export const getDBClustersActionStub = jest.fn(); + +export const dbClusterLogsAPI = { + logs: [ + { + pod: 'testpod1', + container: 'testpod1container1', + logs: ['test pod1', 'logs', '1'], + }, + { + pod: 'testpod1', + container: 'testpod1container2', + logs: ['test pod1', 'logs', '2'], + }, + { + pod: 'testpod1', + logs: ['test pod1', 'events'], + }, + { + pod: 'testpod2', + container: 'testpod2container1', + logs: ['test pod2', 'logs', '1'], + }, + { + pod: 'testpod2', + logs: ['test pod2', 'events'], + }, + { + pod: 'testpod2', + container: 'testpod2container2', + logs: [], + }, + ], +}; + +export const dbCLusterAllocatedResourcesStub: DBClusterAllocatedResources = { + total: { + cpu: { value: 10, units: CpuUnits.MILLI, original: 10 }, + memory: { value: 10, units: ResourcesUnits.GB, original: 10 }, + disk: { value: 100, units: ResourcesUnits.GB, original: 100 }, + }, + allocated: { + cpu: { value: 1, units: CpuUnits.MILLI, original: 1 }, + memory: { value: 3, units: ResourcesUnits.GB, original: 3 }, + disk: { value: 10, units: ResourcesUnits.GB, original: 10 }, + }, +}; + +export const dbClusterExpectedResourcesStub = { + expected: { + memory: { value: 4, units: ResourcesUnits.GB }, + cpu: { value: 4, units: CpuUnits.MILLI }, + disk: { value: 20, units: ResourcesUnits.GB }, + }, +}; + +export const versionsStub = { + '1.0': { + image_path: 'test_image', + image_hash: 'test_hash', + status: DBClusterComponentVersionStatus.available, + critical: false, + default: false, + }, + '2.0': { + image_path: 'test_image', + image_hash: 'test_hash', + status: DBClusterComponentVersionStatus.recommended, + critical: false, + default: true, + }, +}; + +export const xtradbComponentsVersionsStubs = { + versions: [ + { + product: Operators.pxc, + operator: '1', + matrix: { + pxc: versionsStub, + haproxy: versionsStub, + }, + }, + ], +}; + +export const psmdbComponentsVersionsStubs = { + versions: [ + { + product: Operators.psmdb, + operator: '1', + matrix: { + mongod: versionsStub, + }, + }, + ], +}; + +export const resourcesA: ResourcesWithUnits = { + value: 10, + original: 10, + units: ResourcesUnits.BYTES, +}; + +export const resourcesB: ResourcesWithUnits = { + value: 20, + original: 20, + units: ResourcesUnits.BYTES, +}; + +export const resourcesC: ResourcesWithUnits = { + value: 20, + original: 20, + units: ResourcesUnits.GB, +}; diff --git a/public/app/percona/dbaas/components/DBaaSIcons/CPU.tsx b/public/app/percona/dbaas/components/DBaaSIcons/CPU.tsx new file mode 100644 index 0000000000000..c4d1016f2a543 --- /dev/null +++ b/public/app/percona/dbaas/components/DBaaSIcons/CPU.tsx @@ -0,0 +1,12 @@ +import React, { FC, SVGProps } from 'react'; + +export const CPU: FC> = (props) => ( + + + +); diff --git a/public/app/percona/dbaas/components/DBaaSIcons/Disk.tsx b/public/app/percona/dbaas/components/DBaaSIcons/Disk.tsx new file mode 100644 index 0000000000000..0862e5a07cdd0 --- /dev/null +++ b/public/app/percona/dbaas/components/DBaaSIcons/Disk.tsx @@ -0,0 +1,10 @@ +import React, { FC, SVGProps } from 'react'; + +export const Disk: FC> = (props) => ( + + + +); diff --git a/public/app/percona/dbaas/components/DBaaSIcons/Memory.tsx b/public/app/percona/dbaas/components/DBaaSIcons/Memory.tsx new file mode 100644 index 0000000000000..5f0e6c357e281 --- /dev/null +++ b/public/app/percona/dbaas/components/DBaaSIcons/Memory.tsx @@ -0,0 +1,14 @@ +import React, { FC, SVGProps } from 'react'; + +export const Memory: FC> = (props) => ( + + + + +); diff --git a/public/app/percona/dbaas/components/DBaaSIcons/index.ts b/public/app/percona/dbaas/components/DBaaSIcons/index.ts new file mode 100644 index 0000000000000..4af3fefffef01 --- /dev/null +++ b/public/app/percona/dbaas/components/DBaaSIcons/index.ts @@ -0,0 +1,3 @@ +export * from './Memory'; +export * from './Disk'; +export * from './CPU'; diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernatesModal.utils.test.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernatesModal.utils.test.ts new file mode 100644 index 0000000000000..b7cf8f0f745ed --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernatesModal.utils.test.ts @@ -0,0 +1,10 @@ +import kubeConfigFile from './KubeConfigTestMock'; +import { onKubeConfigValueChange } from './AddKubernatesModal.utils'; + +describe('AddKubernetesModal.utils::', () => { + it('getClusterNameFromKubeConfig returns name of cluster', () => { + const mutatorMock = jest.fn(); + onKubeConfigValueChange(kubeConfigFile, mutatorMock); + expect(mutatorMock).toBeCalledWith(kubeConfigFile, 'minikube'); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernatesModal.utils.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernatesModal.utils.ts new file mode 100644 index 0000000000000..47ac152027fd9 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernatesModal.utils.ts @@ -0,0 +1,45 @@ +import { parse } from 'yaml'; +import { KubeConfig, Cluster } from '../Kubernetes.types'; + +export const onKubeConfigValueChange = ( + value: string | undefined, + updateFormMutator: (configValue: string | undefined, nameValue: string | undefined) => void +) => { + const defaultName = getClusterNameFromKubeConfig(value); + updateFormMutator(value, defaultName); +}; + +const getClusterNameFromKubeConfig = (value: string | undefined): string | undefined => { + if (value) { + try { + const parsedYAML: KubeConfig = parse(value); + const clusters = parsedYAML?.clusters; + if (clusters && clusters.length) { + const clusterWithName = clusters.find((item: Cluster) => item?.name != null); + if (clusterWithName) { + return clusterWithName.name; + } + } + } catch (e) { + return undefined; + } + } + return undefined; +}; + +const getFromClipboard = async () => { + if (navigator.clipboard.readText) { + return navigator.clipboard.readText(); + } + return Promise.resolve(undefined); +}; + +export const pasteFromClipboard = async ( + updateFormMutator: (configValue: string | undefined, nameValue: string | undefined) => void +) => { + const kubeConfig = await getFromClipboard(); + if (kubeConfig) { + const defaultName = getClusterNameFromKubeConfig(kubeConfig); + updateFormMutator(kubeConfig, defaultName); + } +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.constants.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.constants.ts new file mode 100644 index 0000000000000..61b8d32cbf32d --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.constants.ts @@ -0,0 +1 @@ +export const AWS_CREDENTIALS_DOC_LINK = 'https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html'; diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.messages.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.messages.ts new file mode 100644 index 0000000000000..71d470e16cd42 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.messages.ts @@ -0,0 +1,11 @@ +export const Messages = { + isEKSCheckboxLabel: 'Using Amazon Elastic Kubernetes Service (EKS)', + isEKSCheckboxTooltip: `If using Amazon EKS and kubeconfig does not contain AWS access key ID + and AWS secret access key please provide them below`, + awsAccessKeyIDLabel: 'AWS Access Key ID', + awsSecretAccessKeyLabel: 'AWS Secret Access Key', + awsAccessKeyIDTooltip: `AWS Access Key ID of the root user or an IAM user with access to the + EKS cluster`, + awsSecretAccessKeyTooltip: `AWS Secret Access Key of the root user or an IAM user with access + to the EKS cluster`, +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.styles.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.styles.ts new file mode 100644 index 0000000000000..ba408b5b783ff --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.styles.ts @@ -0,0 +1,22 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data/src'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + checkbox: css` + span { + top: 0; + } + `, + urlWarningWrapper: css` + margin-bottom: ${spacing.md}; + `, + pasteButton: css` + margin-bottom: ${spacing.md}; + `, + pasteButtonWrapper: css` + display: flex; + width: 100%; + flex-direction: row; + justify-content: flex-end; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.test.tsx b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.test.tsx new file mode 100644 index 0000000000000..a39c9e0234191 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { fireEvent, screen, render } from '@testing-library/react'; +import { AddKubernetesModal } from './AddKubernetesModal'; +import { Router } from 'react-router-dom'; +import { locationService } from '@grafana/runtime'; +import { Provider } from 'react-redux'; +import { configureStore } from 'app/store/configureStore'; +import { StoreState } from 'app/types'; + +describe('AddKubernetesModal::', () => { + it('renders the modal with all elements', () => { + render( + + + {}} setAddModalVisible={() => {}} /> + + + ); + + expect(screen.getByTestId('name-text-input')).toBeInTheDocument(); + expect(screen.getByTestId('kubeConfig-textarea-input')).toBeInTheDocument(); + expect(screen.getByTestId('isEKS-checkbox-input')).toBeInTheDocument(); + expect(screen.queryByTestId('pmm-server-url-warning')).toBeFalsy(); + expect(screen.queryByTestId('kubernetes-paste-from-clipboard-button')).toBeInTheDocument(); + }); + + it('shows PMM Server Url Warning', async () => { + render( + + + {}} setAddModalVisible={() => {}} /> + + + ); + expect(await screen.findByTestId('pmm-server-url-warning')).toBeInTheDocument(); + }); + + it('calls addKubernetes with correct values on registering new cluster', () => { + const addKubernetes = jest.fn(); + + render( + + {}} /> + + ); + + const name = 'Test name'; + const kubeConfig = 'Test config'; + const nameEvent = { target: { value: name } }; + const configEvent = { target: { value: kubeConfig } }; + const expected = { + name, + kubeConfig, + }; + + fireEvent.change(screen.getByTestId('kubeConfig-textarea-input'), configEvent); + fireEvent.change(screen.getByTestId('name-text-input'), nameEvent); + fireEvent.click(screen.getByTestId('kubernetes-add-cluster-button')); + + expect(addKubernetes).toHaveBeenCalledWith(expected, false); + }); + + it('clicking isEKS checkbox shows AWS credentials fields', () => { + render( + + {}} setAddModalVisible={() => {}} /> + + ); + + expect(screen.queryByTestId('awsAccessKeyID-text-input')).not.toBeInTheDocument(); + expect(screen.queryByTestId('awsSecretAccessKey-password-input')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('isEKS-checkbox-input')); + + expect(screen.queryByTestId('awsAccessKeyID-text-input')).toBeInTheDocument(); + expect(screen.queryByTestId('awsSecretAccessKey-password-input')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.tsx b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.tsx new file mode 100644 index 0000000000000..161800c46c629 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { + CheckboxField, + Modal, + PasswordInputField, + TextareaInputField, + TextInputField, + validators, +} from '@percona/platform-core'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { Form, FormRenderProps } from 'react-final-form'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { NewKubernetesCluster } from '../Kubernetes.types'; +import { AddKubernetesModalProps } from './AddKubernetesModal.types'; +import { getStyles } from './AddKubernetesModal.styles'; +import { Messages as ModalMessages } from './AddKubernetesModal.messages'; +import { AWS_CREDENTIALS_DOC_LINK } from './AddKubernetesModal.constants'; +import { PMMServerUrlWarning } from '../../PMMServerURLWarning/PMMServerUrlWarning'; +import { onKubeConfigValueChange, pasteFromClipboard } from './AddKubernatesModal.utils'; +import { useShowPMMAddressWarning } from 'app/percona/shared/components/hooks/showPMMAddressWarning'; + +const { required } = validators; +const { + isEKSCheckboxLabel, + isEKSCheckboxTooltip, + awsAccessKeyIDLabel, + awsAccessKeyIDTooltip, + awsSecretAccessKeyLabel, + awsSecretAccessKeyTooltip, +} = ModalMessages; + +export const AddKubernetesModal = ({ isVisible, addKubernetes, setAddModalVisible }: AddKubernetesModalProps) => { + const styles = useStyles(getStyles); + const [showPMMAddressWarning] = useShowPMMAddressWarning(); + + return ( + setAddModalVisible(false)}> + {showPMMAddressWarning && } +
    { + addKubernetes(values, showPMMAddressWarning); + setAddModalVisible(false); + }} + mutators={{ + setKubeConfigAndName: ([configValue, nameValue]: string[], state, { changeValue }) => { + changeValue(state, 'kubeConfig', () => configValue); + changeValue(state, 'name', () => nameValue); + }, + }} + render={({ handleSubmit, valid, pristine, values: { isEKS }, form }: FormRenderProps) => ( + + <> +
    + +
    + { + onKubeConfigValueChange(event?.target?.value, form.mutators.setKubeConfigAndName); + }, + }} + /> + + {isEKS && ( + <> + + + + )} + + + + + + + )} + /> +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.types.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.types.ts new file mode 100644 index 0000000000000..06d59160339f2 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/AddKubernetesModal.types.ts @@ -0,0 +1,7 @@ +import { AddKubernetesAction } from '../Kubernetes.types'; + +export interface AddKubernetesModalProps { + isVisible: boolean; + addKubernetes: AddKubernetesAction; + setAddModalVisible: (isVisible: boolean) => void; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/KubeConfigTestMock.ts b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/KubeConfigTestMock.ts new file mode 100644 index 0000000000000..18234827d6eb9 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/AddKubernetesModal/KubeConfigTestMock.ts @@ -0,0 +1 @@ +export default 'apiVersion: v1\nclusters:\n- cluster:\n certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCakNDQWU2Z0F3SUJBZ0lCQVRBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwdGFXNXAKYTNWaVpVTkJNQjRYRFRJeU1ETXlNVEV6TXprek1sb1hEVE15TURNeE9URXpNemt6TWxvd0ZURVRNQkVHQTFVRQpBeE1LYldsdWFXdDFZbVZEUVRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTkNxClJmd2NjMEtwYnp6Sk4vSEo2ZzJpeGtibmRPSnVBTGFDMXlwTTBla3hJOCs5RWgxMjVwSjJnZFFzRjhzYWNRYUIKSmZPSWxPdHRzZHlNbmVYV3V4cjVOd29MZVhhYUtSd2U2Kzl4MlZhQUtTbSsySGo4Y1lvb3ZqZlc5RjU0NUFpdwpoa0tDcm02Tm5PZjlIaC9KdkR6clM5V1U0eDM5S25XVE8xcnVqaWdQR3RmYXprR2haSnpMcDFzZ2hXTWViSHA0CmR3QXpkTjB6blRSOWN1Ykd1UXRmZmxPd3BGTU50L0RRNFdVWmEzVS9SL09ZNlg1M3JMNVF2ZjRuTFptRlhmUnoKMldpbEZHNVhUbnVCZzlKZ3FUang1QWpOOGpveFVJMGtEdEFtMW9aOVJucjNJdEEzQm0rTlRKeVQxRkh1K0xYNAp6V2ZSd2hQcXpXY3VXV3FzbTlzQ0F3RUFBYU5oTUY4d0RnWURWUjBQQVFIL0JBUURBZ0trTUIwR0ExVWRKUVFXCk1CUUdDQ3NHQVFVRkJ3TUNCZ2dyQmdFRkJRY0RBVEFQQmdOVkhSTUJBZjhFQlRBREFRSC9NQjBHQTFVZERnUVcKQkJUNmlnbDNZaHJyYU1WYUtxNzU2QnhGUUpOQ3pqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFWZkRJS1Z3MQpCU3JHUFRCR2ZkMCtHczhpR1JvUnNaVGluLzB2dnluL3cyTi9KS05hQjZLSmZqTS9oWDlNRHJTOXdCaHlWMzUxCkh3Z0dWeUVIOHlwY2RYRGR6MUx0azRaRzlhUS9FeW5ZV2tlMkpaem5JSS94Uzc5ZWxCVjhCVlpnbkxEWW5ESWcKQW4zTExnYVRnbEVSSXoyRDRYdG9tSUdWOUxJYzhUSWFIT0xrekpIN2NweFpEM3FzUWh0VEJDR0Nyd2wranh1Mgp1aENpNkd4QzVRUm1GTXBzMEFWYkR2cUk5Nk9kY0o3RUpkZCtsOVplVitKTnJsbjQ3K1A5N1dwYSsyU09LM282CkNtRjlrd25TRXZMTlRRanR0emIvSURWZmtPbFRlSHpUNXdMeGtIRW5FcTdxL0JIYXhTU2d2dFRmT0FveFV5MHQKZDBrV05HVmE5MXlPNFE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==\n extensions:\n - extension:\n last-update: Thu, 28 Jul 2022 19:42:19 +04\n provider: minikube.sigs.k8s.io\n version: v1.24.0\n name: cluster_info\n server: https://192.168.49.2:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n extensions:\n - extension:\n last-update: Thu, 28 Jul 2022 19:42:19 +04\n provider: minikube.sigs.k8s.io\n version: v1.24.0\n name: context_info\n namespace: default\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJVENDQWdtZ0F3SUJBZ0lCQWpBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwdGFXNXAKYTNWaVpVTkJNQjRYRFRJeU1EY3lOekUxTkRJd04xb1hEVEkxTURjeU56RTFOREl3TjFvd01URVhNQlVHQTFVRQpDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGakFVQmdOVkJBTVREVzFwYm1scmRXSmxMWFZ6WlhJd2dnRWlNQTBHCkNTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEaDN4NnpiSUM5dFZ3aWsrN1REQWx4cXovNXR3SnMKRURWd0RkVStYc1hlendxd3dHSFM2M2c5Sy8yOEVhaCt1bUhwUWhPRUJkYnZrYVFrSlpYb0gvWGoranF4TFhCdgorL0ZrQnNwZ2hQZlNXc3NTRVh0VHBGR2J1Q2Z5a0NTcEMvMmNKN2ZTa2tncVRNWWdTamZYbFJtVmM2bStSeEROCkF6djB2TEZGYzE5MHI5YWM4dzRrcDk0YUZNdG96RlBVcm1jZ2xOMnppVmkxRFNoN0E1Q3E1TTZoaUFtM0J0dlUKM3Qrc0w1WE02UnZMMGZVUG5qT2lrOWlYZmdWd1lZU3Q3MEM3ZEhJTHcrdmtwRUFJWlFhVkFMYmwrdHVGYk45bApyOTk2cFk4aWZsbVRBNTBpQm8wT2d1NVNJbXBQcUtXd2ZQUDNkalA1SjBUaWJQK0RDV0c0N0FteEFnTUJBQUdqCllEQmVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQWRCZ05WSFNVRUZqQVVCZ2dyQmdFRkJRY0RBUVlJS3dZQkJRVUgKQXdJd0RBWURWUjBUQVFIL0JBSXdBREFmQmdOVkhTTUVHREFXZ0JUNmlnbDNZaHJyYU1WYUtxNzU2QnhGUUpOQwp6akFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBdms4bEM5aEg5OWZUeXZkT0IyTUdDODY0KzFFaGVVcC9XK3lwClVCdnVXMW9wUVJLLzhaRGVzaE5sUmxkSVhIamdpU2Y1WTZubmtNUTlIcnYxTXQ0YUtKdGlFL3dIdjYveFJYbTUKUFpKbW5pYmFYYVhLQkJvc3JyT1M4RnBWQWh1dUp5SWp2OFNGeE1BWk1kaUpKcnZOVmQzYXIyaVJYdWx6b05kMwp6TEdrRXI3OE5oaVBwVFpxK1dwTEJxVG52TUQreHNUbXJuVHFPdUZoUFRUYVU4ZzJhTkVxWTFIcXYxOVBRZE45Ckl1aDlCVXNyRnI5ZW5UWTJzQlo1b1k2VmFqL0d2ZWVkbEt0RjkvOCtIbzJaYjAvYU9LSkVHaElleHdjT2htcEsKb1JsaExtZGV5VUxoSnNUcUVXcENDMWlmU1BjdWZxMHh3dURwSDBTWld4SjlzOURjMWc9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==\n client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBNGQ4ZXMyeUF2YlZjSXBQdTB3d0pjYXMvK2JjQ2JCQTFjQTNWUGw3RjNzOEtzTUJoCjB1dDRQU3Y5dkJHb2ZycGg2VUlUaEFYVzc1R2tKQ1dWNkIvMTQvbzZzUzF3Yi92eFpBYktZSVQzMGxyTEVoRjcKVTZSUm03Z244cEFrcVF2OW5DZTMwcEpJS2t6R0lFbzMxNVVabFhPcHZrY1F6UU03OUx5eFJYTmZkSy9XblBNTwpKS2ZlR2hUTGFNeFQxSzVuSUpUZHM0bFl0UTBvZXdPUXF1VE9vWWdKdHdiYjFON2ZyQytWek9rYnk5SDFENTR6Cm9wUFlsMzRGY0dHRXJlOUF1M1J5QzhQcjVLUkFDR1VHbFFDMjVmcmJoV3pmWmEvZmVxV1BJbjVaa3dPZElnYU4KRG9MdVVpSnFUNmlsc0h6ejkzWXorU2RFNG16L2d3bGh1T3dKc1FJREFRQUJBb0lCQUE3UjRPWE0zTFdWekxISQpHd2RsNXNpNmY3d1dzZVg5T2tSYjQvM3ZvZlA0aWE4SE1HUHlaelU4U2EycFN2RGxzYjdvUXZlS21vdWxkcXVZCmU3bDdQMXJ4OUIvajUxaXhveWo0K1JaVUl4NStMb3pFOE42UURYcTJIb2pmeEVnRExXU3RoblllZXZXcmkrUmEKZWtkKzFPcmxaK0hBTCs0RHBFOXVnZ01ZaXM3UkN5bXVHczdPeFdOd2c5YUh2YmxIc3NwREIzb0xiMnRid3JFLwpaUVdHbHV2UnV3bjUwQ1dTSXg1SmNudjhPcmQ0ck5OWGMvNjdic2c3VmRiQlJ4T3V2eW14NnRIM1hVUC9sVU9GCmk5ZzVOeVFtZzh5S3lFNyt0L2MzS2p0SWVCb1hKcXhCZ0dEZk9aeWJwc0FseVA1OUR0dmZQMkxuYXRQcW1BVHoKa3ZSeTZBRUNnWUVBOEx1MFVpWW03MXFjNHV0TDVBdGRFbTVlT040ZGtKT3lBYTl5VkV5b00vVXYzMTc5TXAreAp5V2pBNUlWN0duY245NlM2YVV3MXlaaDZnSUJxbFJwbmRpdkFFZ0ZqMGVjS1lvSVJpaHR4OXN6dUVCTWpOMEZQCjR6ejMwckxJVVdTUnNkZG8vVGx5N2U1b2VzU1ZXbDYxdDRlUGxZMUpLNmNpWENpOXhXMUxVbUVDZ1lFQThESWoKRk1RUXdkaVpEWFZ1RmJWM3FzazBaYW1yQ2lxeFBHZm1sOEpIU3pxMmlXRUpTK1RKZ095M1IzVzBrbUFoaEs0SApVQXFzVVBzVDArQVAxTmoxWjNnUWdTWWJrQ1ZSM3o2M2l1VmZLUWs3OHdpalluL2c0WFJEcllhbVdmTDBYOGJwCjk0VVNQQ3dxSXp0RVNISUxCby9zbmRFbDZlR3FuTFk5emRPN21WRUNnWUVBcDI0VmV6RTMwUzlYZ0dlZ1Q4b3IKZ0Y4c3Z5YVVyM0paMHR4QWl5c0pyYUZ4RzAxSWtzWUk5QWtjWjVRQ3k2Um1NdEhxS01RdGdMbkJNZENlMEhjZAowRTJiZDZwcHo1cCtXWWNYUmRQU3pwRTNYZ3pCYUhQUGFUK0ZLWkRZeSt0RGZjcFJKaFdudnA0YklvL0pSS0lzCmhxb05Eam5HMDBxYUZqanJ5LzA0N3VFQ2dZRUE1MG93RTdmMHR1U2VCS0syUFhzL1h4cGVOU0xiQzNBdXVJOEkKTWN3bklKN0oxS0cyOVBpNnZFVzArcit5QUYxSENWOFd0WkdCZW4wN0M0T3ZXdk1MNC9WdVZ4NWQza0RCaEtuOAp6V2V2YVhGMTQ4SEdxbnVmRFJvS2JWYkNhczBUV2dMTm1zWHQyRGxpM2dnYzZYRy9nak1tMHBUcDREdW9NVDBmCmFFcGhVL0VDZ1lCT29kZUxvUm5NdXpicGxqUVMrRzEzKzhLd3A2WWdSU0pxblNNVGRtZnFVMWdVTjA3amF0TXQKN2lkK0tBVlZ6NFBEUWVUTWFXbnFRTllLc3ZDbDQrNkJTc1NvL0FYSGVBWGQwb3djRlhEaDMvNFh5bVIyaEVZYQpwWE9TSkd6enMxOUFDMHdObnZYaHBFWi8rMTFOdTBnR0tjeWp3MnIxUXZpT1U3VkRaSGpMMmc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo='; diff --git a/public/app/percona/dbaas/components/Kubernetes/ColumnRenderers/ColumnRenderers.tsx b/public/app/percona/dbaas/components/Kubernetes/ColumnRenderers/ColumnRenderers.tsx new file mode 100644 index 0000000000000..084291a09be65 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ColumnRenderers/ColumnRenderers.tsx @@ -0,0 +1,21 @@ +/* eslint-disable react/display-name */ +import React from 'react'; +import { KubernetesClusterActions } from '../KubernetesClusterActions/KubernetesClusterActions'; +import { Kubernetes } from '../Kubernetes.types'; + +export const clusterActionsRender = ({ + setSelectedCluster, + setDeleteModalVisible, + setViewConfigModalVisible, + setManageComponentsModalVisible, + getDBClusters, +}: Omit) => (kubernetesCluster: Kubernetes) => ( + +); diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.constants.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.constants.ts new file mode 100644 index 0000000000000..c8c14d1e6a347 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.constants.ts @@ -0,0 +1,17 @@ +import { Databases } from 'app/percona/shared/core'; +import { Operators } from '../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { ComponentToUpdate, DatabaseComponentToUpdateMap } from './Kubernetes.types'; + +export const OPERATOR_COMPONENT_TO_UPDATE_MAP = { + [Operators.pxc]: ComponentToUpdate.pxc, + [Operators.psmdb]: ComponentToUpdate.psmdb, +}; + +export const DATABASE_COMPONENT_TO_UPDATE_MAP = { + [Databases.mysql]: ComponentToUpdate.pxc, + [Databases.mongodb]: ComponentToUpdate.psmdb, +} as DatabaseComponentToUpdateMap; + +export const GET_KUBERNETES_CANCEL_TOKEN = 'getKubernetes'; +export const DELETE_KUBERNETES_CANCEL_TOKEN = 'deleteKubernetes'; +export const CHECK_OPERATOR_UPDATE_CANCEL_TOKEN = 'checkOperatorUpdate'; diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.hooks.constants.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.hooks.constants.ts new file mode 100644 index 0000000000000..2001f58f62214 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.hooks.constants.ts @@ -0,0 +1,3 @@ +export const GET_KUBERNETES_CANCEL_TOKEN = 'getKubernetes'; +export const ADD_KUBERNETES_CANCEL_TOKEN = 'addKubernetes'; +export const CHECK_OPERATOR_UPDATE_CANCEL_TOKEN = 'checkOperatorUpdate'; diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.service.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.service.ts new file mode 100644 index 0000000000000..9eba6b39756ba --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.service.ts @@ -0,0 +1,84 @@ +import { apiManagement } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { + CheckOperatorUpdateAPI, + ComponentToUpdate, + InstallOperatorRequest, + InstallOperatorResponse, + Kubernetes, + KubernetesListAPI, + NewKubernetesCluster, + NewKubernetesClusterAPI, +} from './Kubernetes.types'; + +export const KubernetesService = { + getKubernetes(token?: CancelToken) { + return apiManagement.post('/DBaaS/Kubernetes/List', {}, true, token); + }, + deleteKubernetes(kubernetes: Kubernetes, force?: boolean, token?: CancelToken) { + return apiManagement.post('/DBaaS/Kubernetes/Unregister', toAPI(kubernetes, force), false, token); + }, + getKubernetesConfig(kubernetes: Kubernetes, token?: CancelToken) { + return apiManagement.post('/DBaaS/Kubernetes/Get', toAPI(kubernetes), false, token); + }, + addKubernetes(kubernetes: NewKubernetesCluster, token?: CancelToken) { + return apiManagement.post( + '/DBaaS/Kubernetes/Register', + newClusterToApi(kubernetes), + false, + token + ); + }, + checkForOperatorUpdate(token?: CancelToken) { + return apiManagement.post( + '/DBaaS/Components/CheckForOperatorUpdate', + {}, + false, + token + ); + }, + installOperator( + kubernetesClusterName: string, + operatorType: ComponentToUpdate, + version: string, + token?: CancelToken + ) { + return apiManagement.post( + '/DBaaS/Components/InstallOperator', + { + kubernetes_cluster_name: kubernetesClusterName, + operator_type: operatorType, + version, + }, + false, + token + ); + }, +}; + +const toAPI = (kubernetes: Kubernetes, force?: boolean) => ({ + kubernetes_cluster_name: kubernetes.kubernetesClusterName, + force, +}); + +const newClusterToApi = ({ + name, + kubeConfig, + isEKS, + awsAccessKeyID, + awsSecretAccessKey, +}: NewKubernetesCluster): NewKubernetesClusterAPI => { + const cluster: NewKubernetesClusterAPI = { + kubernetes_cluster_name: name, + kube_auth: { + kubeconfig: kubeConfig, + }, + }; + + if (isEKS) { + cluster.aws_access_key_id = awsAccessKeyID; + cluster.aws_secret_access_key = awsSecretAccessKey; + } + + return cluster; +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.styles.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.styles.ts new file mode 100644 index 0000000000000..e38ed6c94fbf0 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.styles.ts @@ -0,0 +1,17 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme) => ({ + actionPanel: css` + display: flex; + justify-content: flex-end; + margin-bottom: ${theme.spacing.sm}; + `, + actionsColumn: css` + display: flex; + justify-content: center; + `, + deleteModalContent: css` + margin-bottom: ${theme.spacing.xl}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.types.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.types.ts new file mode 100644 index 0000000000000..32c24fb5b0259 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.types.ts @@ -0,0 +1,132 @@ +import { Databases } from 'app/percona/shared/core'; +import { KubernetesClusterStatus } from './KubernetesClusterStatus/KubernetesClusterStatus.types'; +import { KubernetesOperatorStatus } from './OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; + +export interface KubernetesListAPI { + kubernetes_clusters: KubernetesAPI[]; +} + +export interface Operator { + status: KubernetesOperatorStatus; + version?: string; + availableVersion?: string; +} + +export interface OperatorToUpdate extends Operator { + operatorType: ComponentToUpdate; + operatorTypeLabel: string; +} + +export interface OperatorsList { + psmdb: Operator; + pxc: Operator; +} + +export interface KubernetesAPI { + kubernetes_cluster_name: string; + operators: OperatorsList; + status: string; +} + +export interface Kubernetes { + kubernetesClusterName: string; + operators: OperatorsList; + status: KubernetesClusterStatus; +} + +export type AddKubernetesAction = (kubernetesToAdd: NewKubernetesCluster, setPMMAddress?: boolean) => void; +export type SetKubernetesLoadingAction = (loading: boolean) => void; +export type ManageKubernetes = [Kubernetes[], SetKubernetesLoadingAction, boolean]; + +interface KubeAuth { + kubeconfig: string; +} + +export interface NewKubernetesClusterAPI { + kubernetes_cluster_name: string; + kube_auth: KubeAuth; + aws_access_key_id?: string; + aws_secret_access_key?: string; +} + +export interface CheckOperatorUpdateAPI { + cluster_to_components: { + [cluster: string]: ComponentToUpdateAPI; + }; +} + +export interface ComponentToUpdateAPI { + component_to_update_information: OperatorToUpdateAPI; +} + +export interface OperatorToUpdateAPI { + [ComponentToUpdate.psmdb]: ComponentVersionAPI; + [ComponentToUpdate.pxc]: ComponentVersionAPI; +} + +export interface ComponentVersionAPI { + available_version?: string; +} + +export enum ComponentToUpdate { + psmdb = 'psmdb-operator', + pxc = 'pxc-operator', +} + +export type DatabaseComponentToUpdateMap = { [key in Databases]: ComponentToUpdate }; + +export interface InstallOperatorRequest { + kubernetes_cluster_name: string; + operator_type: ComponentToUpdate; + version: string; +} + +export interface InstallOperatorResponse { + status: KubernetesOperatorStatus; +} + +export interface NewKubernetesCluster { + name: string; + kubeConfig: string; + isEKS: boolean; + awsAccessKeyID?: string; + awsSecretAccessKey?: string; +} + +export interface KubernetesProps { + kubernetes: Kubernetes[]; + setLoading: SetKubernetesLoadingAction; + loading: boolean; +} + +export interface KubeConfig { + name?: string; + server?: string; + apiVersion?: string; + clusters: Cluster[]; +} + +export interface Cluster { + name?: string; + cluster?: ClusterInfo; +} + +export interface ClusterInfo { + 'certificate-authority-data'?: string; + extensions?: { + extension?: Extension[]; + name?: string; + }; + server?: string; +} + +interface Extension { + extension?: ExtensionInfo; + name?: string; +} + +interface ExtensionInfo { + 'last-update'?: string; + provider?: string; + version?: string; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.utils.test.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.utils.test.ts new file mode 100644 index 0000000000000..3272522150d30 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.utils.test.ts @@ -0,0 +1,80 @@ +import { Operators } from '../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { DATABASE_OPTIONS } from '../DBCluster/DBCluster.constants'; +import { getActiveOperators, getDatabaseOptionFromOperator, isKubernetesListUnavailable } from './Kubernetes.utils'; +import { KubernetesClusterStatus } from './KubernetesClusterStatus/KubernetesClusterStatus.types'; +import { KubernetesOperatorStatus } from './OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { kubernetesStub } from './__mocks__/kubernetesStubs'; + +describe('Kubernetes.utils:: ', () => { + it('should return false when there are clusters available', () => { + expect(isKubernetesListUnavailable(kubernetesStub)).toBeFalsy(); + }); + it('should return true when there are no clusters available', () => { + const kubernetes = [ + { ...kubernetesStub[0], status: KubernetesClusterStatus.invalid }, + { ...kubernetesStub[1], status: KubernetesClusterStatus.unavailable }, + ]; + expect(isKubernetesListUnavailable(kubernetes)).toBeTruthy(); + }); + it('should return empty list of active operators', () => { + const kubernetes = [ + { + ...kubernetesStub[0], + operators: { + psmdb: { status: KubernetesOperatorStatus.invalid }, + pxc: { status: KubernetesOperatorStatus.unavailable }, + }, + }, + { + ...kubernetesStub[1], + operators: { + psmdb: { status: KubernetesOperatorStatus.unsupported }, + pxc: { status: KubernetesOperatorStatus.unavailable }, + }, + }, + ]; + expect(getActiveOperators(kubernetes).length).toBe(0); + }); + it('should return list of with all active operators', () => { + const kubernetes = [ + { + ...kubernetesStub[0], + operators: { psmdb: { status: KubernetesOperatorStatus.ok }, pxc: { status: KubernetesOperatorStatus.ok } }, + }, + { + ...kubernetesStub[1], + operators: { + psmdb: { status: KubernetesOperatorStatus.ok }, + pxc: { status: KubernetesOperatorStatus.unavailable }, + }, + }, + ]; + expect(getActiveOperators(kubernetes).length).toBe(2); + }); + it('should return list with one active operator', () => { + const kubernetes = [ + { + ...kubernetesStub[0], + operators: { + psmdb: { status: KubernetesOperatorStatus.ok }, + pxc: { status: KubernetesOperatorStatus.unavailable }, + }, + }, + { + ...kubernetesStub[1], + operators: { + psmdb: { status: KubernetesOperatorStatus.unsupported }, + pxc: { status: KubernetesOperatorStatus.unavailable }, + }, + }, + ]; + const activeOperators = getActiveOperators(kubernetes); + + expect(activeOperators.length).toBe(1); + expect(activeOperators[0]).toEqual(Operators.psmdb); + }); + it('returns correct database option from operator', () => { + expect(getDatabaseOptionFromOperator(Operators.pxc)).toEqual(DATABASE_OPTIONS[0]); + expect(getDatabaseOptionFromOperator(Operators.psmdb)).toEqual(DATABASE_OPTIONS[1]); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/Kubernetes.utils.ts b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.utils.ts new file mode 100644 index 0000000000000..de66b76cd6b22 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/Kubernetes.utils.ts @@ -0,0 +1,34 @@ +import { + DatabaseOption, + Operators, +} from '../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { Kubernetes, Operator } from './Kubernetes.types'; +import { KubernetesClusterStatus } from './KubernetesClusterStatus/KubernetesClusterStatus.types'; +import { KubernetesOperatorStatus } from './OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { ActiveOperatorsMap } from '../DBCluster/DBCluster.types'; +import { DATABASE_OPERATORS, DATABASE_OPTIONS } from '../DBCluster/DBCluster.constants'; + +export const isKubernetesListUnavailable = (kubernetes: Kubernetes[]) => + !!!kubernetes.find((k) => k.status === KubernetesClusterStatus.ok); + +export const getActiveOperators = (kubernetes: Kubernetes[]): Operators[] => { + const activeOperatorsMap: ActiveOperatorsMap = {}; + + const activeOperators = kubernetes.reduce((acc: Operators[], k) => { + const activeOperators: Operators[] = []; + + Object.entries(k.operators).forEach(([operator, { status }]: [string, Operator]) => { + if (!activeOperatorsMap[operator as Operators] && status === KubernetesOperatorStatus.ok) { + activeOperators.push(operator as Operators); + activeOperatorsMap[operator as Operators] = true; + } + }); + + return [...acc, ...activeOperators]; + }, []); + + return activeOperators; +}; + +export const getDatabaseOptionFromOperator = (operator: Operators): DatabaseOption | undefined => + DATABASE_OPTIONS.find(({ value }) => value === DATABASE_OPERATORS[operator]); diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.styles.ts b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.styles.ts new file mode 100644 index 0000000000000..c8191103b7a73 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.styles.ts @@ -0,0 +1,8 @@ +import { css } from '@emotion/css'; + +export const styles = { + actionsColumn: css` + display: flex; + justify-content: center; + `, +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.test.tsx b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.test.tsx new file mode 100644 index 0000000000000..c60145c5059a7 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { KubernetesClusterActions } from './KubernetesClusterActions'; +import { kubernetesStub } from '../__mocks__/kubernetesStubs'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('KubernetesClusterActions::', () => { + it('renders correctly', async () => { + render( + + ); + + expect(screen.getByTestId('dropdown-menu-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('dropdown-menu-container')).toBeInTheDocument(); + }); + + it('Select delete actions', async () => { + const setSelectedCluster = jest.fn(); + const setDeleteModalVisible = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByText('Unregister')); + + expect(setSelectedCluster).toHaveBeenCalled(); + expect(setDeleteModalVisible).toHaveBeenCalled(); + }); + + it('Select view cluster config action', async () => { + const setSelectedCluster = jest.fn(); + const setDeleteModalVisible = jest.fn(); + const setViewConfigModalVisible = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByText('Show configuration')); + + expect(setSelectedCluster).toHaveBeenCalled(); + expect(setViewConfigModalVisible).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.tsx b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.tsx new file mode 100644 index 0000000000000..ae216aef835e5 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.tsx @@ -0,0 +1,59 @@ +import React, { FC, useCallback } from 'react'; +import { config } from '@grafana/runtime'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { MultipleActions, Action } from 'app/percona/dbaas/components/MultipleActions'; +import { DBClusterActionsProps } from './KubernetesClusterActions.types'; +import { styles } from './KubernetesClusterActions.styles'; +import { Kubernetes } from '../Kubernetes.types'; +import { hasActiveOperator } from '../OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.utils'; + +export const KubernetesClusterActions: FC = ({ + kubernetesCluster, + setSelectedCluster, + setDeleteModalVisible, + setViewConfigModalVisible, + setManageComponentsModalVisible, + getDBClusters, +}) => { + const isAdmin = config.bootData.user.isGrafanaAdmin; + const getActions = useCallback( + (kubernetesCluster: Kubernetes) => { + const actions: Action[] = [ + { + title: Messages.kubernetes.deleteAction, + action: () => { + setSelectedCluster(kubernetesCluster); + setDeleteModalVisible(true); + }, + }, + { + title: Messages.kubernetes.showConfiguration, + action: () => { + setSelectedCluster(kubernetesCluster); + setViewConfigModalVisible(true); + }, + }, + ]; + + if (isAdmin) { + actions.push({ + title: Messages.kubernetes.manageComponents, + disabled: !hasActiveOperator(kubernetesCluster), + action: () => { + setSelectedCluster(kubernetesCluster); + setManageComponentsModalVisible(true); + }, + }); + } + + return actions; + }, + [isAdmin, setSelectedCluster, setDeleteModalVisible, setManageComponentsModalVisible, setViewConfigModalVisible] + ); + + return ( +
    + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.types.ts b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.types.ts new file mode 100644 index 0000000000000..96b27b2f229d9 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterActions/KubernetesClusterActions.types.ts @@ -0,0 +1,10 @@ +import { Kubernetes } from '../Kubernetes.types'; + +export interface DBClusterActionsProps { + kubernetesCluster: Kubernetes; + setSelectedCluster: (kubernetesCluster: Kubernetes) => void; + setDeleteModalVisible: (isVisible: boolean) => void; + setViewConfigModalVisible: (isVisible: boolean) => void; + setManageComponentsModalVisible: (isVisible: boolean) => void; + getDBClusters: () => void; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.constants.ts b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.constants.ts new file mode 100644 index 0000000000000..4ac45c9ac311f --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.constants.ts @@ -0,0 +1,7 @@ +import { KubernetesClusterStatus } from './KubernetesClusterStatus.types'; + +export const STATUS_DATA_QA = { + [KubernetesClusterStatus.invalid]: 'invalid', + [KubernetesClusterStatus.ok]: 'ok', + [KubernetesClusterStatus.unavailable]: 'unavailable', +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.styles.ts b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.styles.ts new file mode 100644 index 0000000000000..973a597b8b617 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.styles.ts @@ -0,0 +1,41 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ colors, typography, v1 }: GrafanaTheme2) => ({ + clusterStatusWrapper: css` + align-items: center; + display: flex; + justify-content: center; + padding: ${v1.spacing.xs} 0; + + a > span { + cursor: pointer; + } + `, + status: css` + background-color: ${v1.palette.gray1}; + border-radius: 20px; + color: ${v1.palette.gray85}; + cursor: default; + font-size: ${typography.size.sm}; + padding: 3px 15px; + display: flex; + `, + statusActive: css` + background-color: ${v1.palette.brandSuccess}; + label: active; + `, + statusFailed: css` + background-color: ${v1.palette.brandDanger}; + label: failed; + `, + statusUnsupported: css` + background-color: ${v1.palette.gray1}; + `, + statusUnavailable: css` + background-color: ${colors.primary.main}; + `, + installLinkIcon: css` + margin-left: ${v1.spacing.xs}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.test.tsx b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.test.tsx new file mode 100644 index 0000000000000..cbf87112710d0 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { KubernetesClusterStatus as Status } from './KubernetesClusterStatus.types'; +import { KubernetesClusterStatus } from './KubernetesClusterStatus'; +import { render, screen } from '@testing-library/react'; + +describe('DBClusterStatus::', () => { + it('renders correctly when ok', () => { + render(); + + expect(screen.getByTestId('cluster-status-ok')).toBeInTheDocument(); + }); + it('renders correctly when invalid', () => { + render(); + + expect(screen.getByTestId('cluster-status-invalid')).toBeInTheDocument(); + }); + it('renders correctly when unavailable', () => { + render(); + + expect(screen.getByTestId('cluster-status-unavailable')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.tsx b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.tsx new file mode 100644 index 0000000000000..b96156f2c390b --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.tsx @@ -0,0 +1,27 @@ +import React, { FC, useMemo } from 'react'; +import { cx } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { getStyles } from './KubernetesClusterStatus.styles'; +import { KubernetesClusterStatus as Status, KubernetesClusterStatusProps } from './KubernetesClusterStatus.types'; +import { STATUS_DATA_QA } from './KubernetesClusterStatus.constants'; + +export const KubernetesClusterStatus: FC = ({ status }) => { + const styles = useStyles2(getStyles); + const statusStyles = useMemo( + () => ({ + [styles.statusActive]: status === Status.ok, + [styles.statusFailed]: status === Status.invalid, + [styles.statusUnavailable]: status === Status.unavailable, + }), + [status, styles.statusActive, styles.statusFailed, styles.statusUnavailable] + ); + + return ( +
    + + {Messages.kubernetes.kubernetesStatus[status]} + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.types.ts b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.types.ts new file mode 100644 index 0000000000000..e4a1e4b4f30b2 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesClusterStatus/KubernetesClusterStatus.types.ts @@ -0,0 +1,9 @@ +export enum KubernetesClusterStatus { + invalid = 'KUBERNETES_CLUSTER_STATUS_INVALID', + ok = 'KUBERNETES_CLUSTER_STATUS_OK', + unavailable = 'KUBERNETES_CLUSTER_STATUS_UNAVAILABLE', +} + +export interface KubernetesClusterStatusProps { + status: KubernetesClusterStatus; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesInventory.test.tsx b/public/app/percona/dbaas/components/Kubernetes/KubernetesInventory.test.tsx new file mode 100644 index 0000000000000..f2778345f287a --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesInventory.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { KubernetesInventory } from './KubernetesInventory'; +import { KubernetesClusterStatus } from './KubernetesClusterStatus/KubernetesClusterStatus.types'; +import { KubernetesOperatorStatus } from './OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; + +jest.mock('app/core/app_events'); +jest.mock('app/percona/dbaas/components/Kubernetes/Kubernetes.service'); + +describe('KubernetesInventory::', () => { + it('renders table correctly', async () => { + render( + + + + ); + + await waitForElementToBeRemoved(() => screen.getByTestId('table-loading')); + expect(screen.getAllByTestId('table-row')).toHaveLength(2); + }); + + it('shows portal k8s free cluster promoting message when user has no clusters', async () => { + render( + + + + ); + + expect(screen.getByTestId('pmm-server-promote-portal-k8s-cluster-message')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/KubernetesInventory.tsx b/public/app/percona/dbaas/components/Kubernetes/KubernetesInventory.tsx new file mode 100644 index 0000000000000..7416b63af59c0 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/KubernetesInventory.tsx @@ -0,0 +1,234 @@ +/* eslint-disable react/display-name */ +import React, { FC, useCallback, useState, useMemo, useEffect } from 'react'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { Column } from 'react-table'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, CheckboxField } from '@percona/platform-core'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import Page from 'app/core/components/Page/Page'; +import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; +import { TechnicalPreview } from 'app/percona/shared/components/Elements/TechnicalPreview/TechnicalPreview'; +import { fetchKubernetesAction, deleteKubernetesAction, addKubernetesAction } from 'app/percona/shared/core/reducers'; +import { + getKubernetes as getKubernetesSelector, + getDeleteKubernetes, + getAddKubernetes, + getPerconaSettingFlag, +} from 'app/percona/shared/core/selectors'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { Table } from 'app/percona/shared/components/Elements/Table/Table'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { Form } from 'react-final-form'; +import { Databases } from 'app/percona/shared/core'; +import { getStyles } from './Kubernetes.styles'; +import { Kubernetes, OperatorToUpdate, NewKubernetesCluster } from './Kubernetes.types'; +import { AddClusterButton } from '../AddClusterButton/AddClusterButton'; +import { OperatorStatusItem } from './OperatorStatusItem/OperatorStatusItem'; +import { KubernetesClusterStatus } from './KubernetesClusterStatus/KubernetesClusterStatus'; +import { clusterActionsRender } from './ColumnRenderers/ColumnRenderers'; +import { ViewClusterConfigModal } from './ViewClusterConfigModal/ViewClusterConfigModal'; +import { ManageComponentsVersionsModal } from './ManageComponentsVersionsModal/ManageComponentsVersionsModal'; +import { UpdateOperatorModal } from './OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal'; +import { AddKubernetesModal } from './AddKubernetesModal/AddKubernetesModal'; +import { + GET_KUBERNETES_CANCEL_TOKEN, + CHECK_OPERATOR_UPDATE_CANCEL_TOKEN, + DELETE_KUBERNETES_CANCEL_TOKEN, +} from './Kubernetes.constants'; +import { PortalK8sFreeClusterPromotingMessage } from './PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage'; + +export const KubernetesInventory: FC = () => { + const styles = useStyles(getStyles); + const dispatch = useDispatch(); + const navModel = usePerconaNavModel('kubernetes'); + const [selectedCluster, setSelectedCluster] = useState(null); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [viewConfigModalVisible, setViewConfigModalVisible] = useState(false); + const [addModalVisible, setAddModalVisible] = useState(false); + const [manageComponentsModalVisible, setManageComponentsModalVisible] = useState(false); + const [operatorToUpdate, setOperatorToUpdate] = useState(null); + const [updateOperatorModalVisible, setUpdateOperatorModalVisible] = useState(false); + const [generateToken] = useCancelToken(); + const { result: kubernetes = [], loading: kubernetesLoading } = useSelector(getKubernetesSelector); + const { loading: deleteKubernetesLoading } = useSelector(getDeleteKubernetes); + const { loading: addKubernetesLoading } = useSelector(getAddKubernetes); + const loading = kubernetesLoading || deleteKubernetesLoading || addKubernetesLoading; + + const deleteKubernetesCluster = useCallback( + async (force?: boolean) => { + if (selectedCluster) { + await dispatch(deleteKubernetesAction({ kubernetesToDelete: selectedCluster, force })); + setDeleteModalVisible(false); + setSelectedCluster(null); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedCluster] + ); + + const columns = useMemo( + (): Array> => [ + { + Header: Messages.kubernetes.table.nameColumn, + accessor: 'kubernetesClusterName', + }, + { + Header: Messages.kubernetes.table.clusterStatusColumn, + accessor: (element: Kubernetes) => , + }, + { + Header: Messages.kubernetes.table.operatorsColumn, + accessor: (element: Kubernetes) => ( +
    + + +
    + ), + }, + { + Header: Messages.kubernetes.table.actionsColumn, + accessor: (kubernetesCluster: Kubernetes) => + clusterActionsRender({ + setSelectedCluster, + setDeleteModalVisible, + setViewConfigModalVisible, + setManageComponentsModalVisible, + })(kubernetesCluster), + }, + ], + [] + ); + + const AddNewClusterButton = useCallback( + () => ( + setAddModalVisible(!addModalVisible)} + data-testid="kubernetes-new-cluster-button" + /> + ), + [addModalVisible] + ); + + const addKubernetes = useCallback(async (cluster: NewKubernetesCluster, setPMMAddress = false) => { + await dispatch( + addKubernetesAction({ + kubernetesToAdd: cluster, + setPMMAddress, + token: generateToken(DELETE_KUBERNETES_CANCEL_TOKEN), + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const featureSelector = useCallback(getPerconaSettingFlag('dbaasEnabled'), []); + + useEffect(() => { + dispatch( + fetchKubernetesAction({ + kubernetes: generateToken(GET_KUBERNETES_CANCEL_TOKEN), + operator: generateToken(CHECK_OPERATOR_UPDATE_CANCEL_TOKEN), + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + +
    +
    + +
    + {selectedCluster && ( + setViewConfigModalVisible(false)} + selectedCluster={selectedCluster} + /> + )} + + setDeleteModalVisible(false)} + > +
    {}} + render={({ form, handleSubmit }) => ( + + <> +

    {Messages.kubernetes.deleteModal.confirmMessage}

    + + + + + + + + )} + /> +
    + {selectedCluster && manageComponentsModalVisible && ( + + )} + {selectedCluster && operatorToUpdate && updateOperatorModalVisible && ( + + )} +
    } /> + + + {kubernetes.length === 0 && } + + + ); +}; + +export default KubernetesInventory; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.hooks.test.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.hooks.test.ts new file mode 100644 index 0000000000000..be8a96d5e6103 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.hooks.test.ts @@ -0,0 +1,67 @@ +// eslint-disable-next-line lodash/import-scope +import * as _ from 'lodash'; +import { renderHook } from '@testing-library/react-hooks'; +import { kubernetesStub } from '../__mocks__/kubernetesStubs'; +import { useOperatorsComponentsVersions } from './ManageComponentsVersions.hooks'; +import { + psmdbComponentOptionsStubs, + initialValuesStubs, + operatorsOptionsStubs, + possibleComponentOptionsStubs, + versionsStubs, + versionsFieldNameStub, + omitDefaultLabels, +} from './__mocks__/componentsVersionsStubs'; + +jest.mock('../../DBCluster/XtraDB.service'); +jest.mock('../../DBCluster/PSMDB.service'); + +describe('ManageComponentsVersions.hooks::', () => { + it('returns operator components options, versions and initial values with two operators', async () => { + const wrapper = renderHook(() => useOperatorsComponentsVersions(kubernetesStub[0])); + + await wrapper.waitForNextUpdate(); + + const [ + initialValues, + operatorsOptions, + componentOptions, + possibleComponentOptions, + versionsOptions, + versionsFieldName, + ] = wrapper.result.current; + + expect(_.omit(initialValues, omitDefaultLabels)).toEqual(_.omit(initialValuesStubs, omitDefaultLabels)); + expect(possibleComponentOptions).toEqual(possibleComponentOptionsStubs); + expect(operatorsOptions).toEqual(operatorsOptionsStubs); + expect(componentOptions).toEqual(psmdbComponentOptionsStubs); + expect(versionsOptions).toEqual(versionsStubs); + expect(versionsFieldName).toEqual(versionsFieldNameStub); + }); + it('returns operator components options, versions and initial values with one operator', async () => { + const newOps = _.omit(kubernetesStub[0], 'operators.pxc'); + const wrapper = renderHook(() => useOperatorsComponentsVersions(newOps)); + await wrapper.waitForNextUpdate(); + + const [ + initialValues, + operatorsOptions, + componentOptions, + possibleComponentOptions, + versionsOptions, + versionsFieldName, + ] = wrapper.result.current; + + expect(_.omit(initialValues, omitDefaultLabels)).toEqual( + _.omit( + initialValuesStubs, + ['pxcpxc', 'pxchaproxy', 'pxcpxcdefault', 'pxchaproxydefault'].concat(omitDefaultLabels) + ) + ); + expect(possibleComponentOptions).toEqual(_.omit(possibleComponentOptionsStubs, 'pxc')); + expect(operatorsOptions).toEqual([operatorsOptionsStubs[0]]); + expect(componentOptions).toEqual(psmdbComponentOptionsStubs); + expect(versionsOptions).toEqual(versionsStubs); + expect(versionsFieldName).toEqual(versionsFieldNameStub); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.hooks.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.hooks.ts new file mode 100644 index 0000000000000..e1e19fc53432b --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.hooks.ts @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { logger } from '@percona/platform-core'; +import { Databases } from 'app/percona/shared/core'; +import { Kubernetes } from '../Kubernetes.types'; +import { KubernetesOperatorStatus } from '../OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { newDBClusterService } from '../../DBCluster/DBCluster.utils'; +import { DATABASE_OPERATORS } from '../../DBCluster/DBCluster.constants'; +import { Operators } from '../../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { + buildDefaultFieldName, + buildVersionsFieldName, + componentsToOptions, + findDefaultVersion, + parseDefaultVersionsOptions, + versionsToOptions, +} from './ManageComponentsVersions.utils'; +import { Messages } from './ManageComponentsVersionsModal.messages'; +import { + ManageComponentsVersionsRenderProps, + ManageComponentVersionsFields, + SupportedComponents, + PossibleComponentOptions, + SetComponentOptionsAction, + SetVersionsOptionsAction, + SetVersionsFieldNameAction, + SetDefaultFieldNameAction, +} from './ManageComponentsVersionsModal.types'; +import { DBClusterVersion } from '../../DBCluster/DBCluster.types'; +import { DEFAULT_SUFFIX } from './ManageComponentsVersionsModal.constants'; + +export const useOperatorsComponentsVersions = ( + kubernetes: Kubernetes +): [ + ManageComponentsVersionsRenderProps, + SelectableValue[], + SelectableValue[], + PossibleComponentOptions, + SelectableValue[], + string, + string, + boolean, + SetComponentOptionsAction, + SetVersionsOptionsAction, + SetVersionsFieldNameAction, + SetDefaultFieldNameAction +] => { + const [initialValues, setInitialValues] = useState({} as ManageComponentsVersionsRenderProps); + const [operatorsOptions, setOperatorsOptions] = useState([]); + const [componentOptions, setComponentOptions] = useState([]); + const [possibleComponentOptions, setPossibleComponentOptions] = useState({} as PossibleComponentOptions); + const [versionsOptions, setVersionsOptions] = useState([]); + const [versionsFieldName, setVersionsFieldName] = useState(''); + const [defaultFieldName, setDefaultFieldName] = useState(DEFAULT_SUFFIX); + const [loadingComponents, setLoadingComponents] = useState(false); + + useEffect(() => { + const getComponents = async () => { + try { + const { operators, kubernetesClusterName } = kubernetes; + const operatorsList = Object.entries(operators); + let availableOperatorOptions = [] as SelectableValue[]; + let possibleComponentOptions = {} as PossibleComponentOptions; + let initialValues = {} as ManageComponentsVersionsRenderProps; + + setLoadingComponents(true); + + for (const [operator, { status }] of operatorsList) { + if (status === KubernetesOperatorStatus.ok) { + [availableOperatorOptions, possibleComponentOptions, initialValues] = await getOperatorComponentsOptions( + operator as Operators, + kubernetesClusterName, + availableOperatorOptions, + possibleComponentOptions, + initialValues + ); + } + } + + const name = buildVersionsFieldName(initialValues) as string; + const defaultName = buildDefaultFieldName(initialValues) as string; + const selectedOperator = initialValues.operator.value as Operators; + + setComponentOptions(possibleComponentOptions[selectedOperator] as SelectableValue[]); + setOperatorsOptions(availableOperatorOptions); + setPossibleComponentOptions(possibleComponentOptions); + setVersionsOptions(initialValues[name]); + setVersionsFieldName(name); + setDefaultFieldName(defaultName); + setInitialValues(initialValues); + } catch (e) { + logger.error(e); + } finally { + setLoadingComponents(false); + } + }; + getComponents(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [ + initialValues, + operatorsOptions, + componentOptions, + possibleComponentOptions, + versionsOptions, + versionsFieldName, + defaultFieldName, + loadingComponents, + setComponentOptions, + setVersionsOptions, + setVersionsFieldName, + setDefaultFieldName, + ]; +}; + +const getOperatorComponentsOptions = async ( + operator: Operators, + kubernetesClusterName: string, + operatorOptions: SelectableValue[], + componentOptions: PossibleComponentOptions, + initialValues: ManageComponentsVersionsRenderProps +): Promise<[SelectableValue[], PossibleComponentOptions, ManageComponentsVersionsRenderProps]> => { + const service = newDBClusterService(DATABASE_OPERATORS[operator] as Databases); + const components = await service.getComponents(kubernetesClusterName); + const operatorVersion = components.versions[0]; + const options = componentsToOptions(operatorVersion.matrix); + const newComponentOptions = { + ...componentOptions, + [operator]: options, + }; + const newOperatorOptions = [ + ...operatorOptions, + { + name: operator, + value: operator, + label: Messages.operatorLabel[operator](operatorVersion.operator), + }, + ]; + const newInitialValues = buildInitialValues(initialValues, operator, operatorVersion, newOperatorOptions, options); + + return [newOperatorOptions, newComponentOptions, newInitialValues]; +}; + +const buildInitialValues = ( + initialValues: ManageComponentsVersionsRenderProps, + operator: Operators, + operatorVersion: DBClusterVersion, + operatorOptions: SelectableValue[], + componentOptions: SelectableValue[] +): ManageComponentsVersionsRenderProps => { + const newInitialValues = {} as ManageComponentsVersionsRenderProps; + + if (Object.keys(initialValues).length === 0) { + newInitialValues[ManageComponentVersionsFields.operator] = operatorOptions[0]; + newInitialValues[ManageComponentVersionsFields.component] = componentOptions[0]; + } + + Object.keys(SupportedComponents).forEach((key) => { + const versions = operatorVersion.matrix[key as SupportedComponents]; + + if (versions) { + const versionsOptions = versionsToOptions(versions); + + newInitialValues[`${operator}${key}`] = versionsOptions; + newInitialValues[`${operator}${key}${DEFAULT_SUFFIX}`] = parseDefaultVersionsOptions([ + findDefaultVersion(versionsOptions) as SelectableValue, + ])[0]; + } + }); + + return { ...initialValues, ...newInitialValues }; +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.test.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.test.ts new file mode 100644 index 0000000000000..45a31f81c546d --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.test.ts @@ -0,0 +1,55 @@ +import { psmdbComponentOptionsStubs, versionsStubs as versions } from './__mocks__/componentsVersionsStubs'; +import { + buildVersionsFieldName, + componentsToOptions, + findDefaultVersion, + findRecommendedVersions, + getDefaultOptions, + requiredVersions, + versionsToOptions, +} from './ManageComponentsVersions.utils'; +import { psmdbComponentsVersionsStubs, versionsStub as versionsAPI } from '../../DBCluster/__mocks__/dbClustersStubs'; +import { ManageComponentVersionsFields } from './ManageComponentsVersionsModal.types'; + +describe('ManageComponentsVersions.utils::', () => { + const values = { + [ManageComponentVersionsFields.operator]: { value: 'test_operator' }, + [ManageComponentVersionsFields.component]: { value: 'test_component' }, + }; + + it('checks that at least one version is checked', () => { + expect(requiredVersions(versions)).toBeUndefined(); + expect(requiredVersions(versions.map((v) => ({ ...v, value: false })))).not.toBeUndefined(); + }); + it('converts components to options', () => { + const components = psmdbComponentsVersionsStubs.versions[0].matrix; + + expect(componentsToOptions(components)).toEqual(psmdbComponentOptionsStubs); + }); + it('converts versions to options', () => { + expect(versionsToOptions(versionsAPI)).toEqual(versions); + }); + it('builds versions field name', () => { + expect(buildVersionsFieldName(values)).toEqual('test_operatortest_component'); + }); + it('finds recommended versions', () => { + expect(findRecommendedVersions(versions).length).toBe(1); + expect(findRecommendedVersions([versions[0]]).length).toBe(0); + }); + it('finds default version', () => { + expect(findDefaultVersion(versions)).toBe(versions[1]); + expect(findDefaultVersion([versions[0]])).toBeUndefined(); + }); + it('returns default options', () => { + const options = [ + { name: 'test option 1', value: true }, + { name: 'test option 2', value: false }, + { name: 'test option 3', value: true }, + ]; + const newValues = { + ...values, + test_operatortest_component: options, + }; + expect(getDefaultOptions(newValues).length).toBe(2); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.tsx b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.tsx new file mode 100644 index 0000000000000..6d4a7b99bccb2 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersions.utils.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { DBClusterComponent, DBClusterComponentVersionStatus, DBClusterMatrix } from '../../DBCluster/DBCluster.types'; +import { DEFAULT_SUFFIX, VERSION_PREFIX } from './ManageComponentsVersionsModal.constants'; +import { Messages } from './ManageComponentsVersionsModal.messages'; +import { + ManageComponentsVersionsRenderProps, + ManageComponentVersionsFields, + SupportedComponents, +} from './ManageComponentsVersionsModal.types'; +import { OptionContent } from '../../DBCluster/OptionContent/OptionContent'; + +export const requiredVersions = (versions: SelectableValue[]) => { + if (!versions || !Array.isArray(versions)) { + return undefined; + } + + const checked = versions.filter((v) => v.value); + + return checked.length > 0 ? undefined : Messages.required; +}; + +export const componentsToOptions = (value: DBClusterMatrix): SelectableValue[] => + Object.keys(value) + .filter((key) => key in SupportedComponents) + .map((key) => ({ + name: key, + value: key, + label: Messages.componentLabel[key as SupportedComponents], + })); + +export const versionsToOptions = (component: DBClusterComponent): SelectableValue[] => + Object.entries(component).map(([key, { status, disabled, default: isDefault }]) => ({ + name: `${VERSION_PREFIX}${key}`, + value: !disabled, + label: key, + status, + default: isDefault, + })); + +export const buildVersionsFieldName = (values: ManageComponentsVersionsRenderProps) => { + if (!values[ManageComponentVersionsFields.operator] || !values[ManageComponentVersionsFields.component]) { + return undefined; + } + + return `${values[ManageComponentVersionsFields.operator].value}${ + values[ManageComponentVersionsFields.component].value + }`; +}; + +export const buildDefaultFieldName = (values: ManageComponentsVersionsRenderProps) => { + if (!values[ManageComponentVersionsFields.operator] || !values[ManageComponentVersionsFields.component]) { + return undefined; + } + + return `${values[ManageComponentVersionsFields.operator].value}${ + values[ManageComponentVersionsFields.component].value + }${DEFAULT_SUFFIX}`; +}; + +export const findRecommendedVersions = (versions: SelectableValue[]) => + versions.filter(({ status }) => status === DBClusterComponentVersionStatus.recommended); + +export const findDefaultVersion = (versions: SelectableValue[]): SelectableValue | undefined => + versions.find(({ default: isDefault }) => isDefault); + +export const getDefaultOptions = (values: ManageComponentsVersionsRenderProps): SelectableValue[] => { + const versionsFieldName = buildVersionsFieldName(values); + const options = (versionsFieldName ? values[versionsFieldName] : []) as SelectableValue[]; + + return options.filter(({ value }) => value); +}; + +export const defaultRequired = (option: SelectableValue) => (option && option.label ? undefined : Messages.required); + +export const parseDefaultVersionsOptions = (options: SelectableValue[]) => + options.map(({ label, status, ...option }) => ({ + ...option, + status, + label: ( + + ), + })); diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.constants.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.constants.ts new file mode 100644 index 0000000000000..f8203e344a3ea --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_SUFFIX = 'default'; +export const VERSION_PREFIX = 'v'; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.messages.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.messages.ts new file mode 100644 index 0000000000000..fb95fd70ee9cf --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.messages.ts @@ -0,0 +1,26 @@ +import { Operators } from '../../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; + +export const Messages = { + cancel: 'Cancel', + fields: { + component: 'Component', + versions: 'Versions', + operator: 'Operator', + default: 'Default', + }, + title: 'Manage Components Versions', + required: 'Required field', + recommended: 'Recommended', + save: 'Save', + success: 'Components versions updated successfully', + operatorLabel: { + [Operators.pxc]: (version: string) => `Percona Operator for MySQL ${version}`, + [Operators.psmdb]: (version: string) => `Percona Operator for MongoDB ${version}`, + }, + componentLabel: { + pxc: 'Percona Operator for MySQL', + haproxy: 'HAProxy', + backup: 'Backup', + mongod: 'Percona Operator for MongoDB', + }, +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.styles.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.styles.ts new file mode 100644 index 0000000000000..fcd065e7a1a0c --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + versionsWrapper: css` + min-height: 40px; + max-height: 200px; + `, + defaultWrapper: css` + div[class$='-Menu'], + div[class$='-grafana-select-menu'] { + svg { + display: none; + } + } + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.test.tsx b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.test.tsx new file mode 100644 index 0000000000000..9ee57662be752 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { ManageComponentsVersionsModal } from './ManageComponentsVersionsModal'; +import { kubernetesStub } from '../__mocks__/kubernetesStubs'; +import { + operatorsOptionsStubs, + psmdbComponentOptionsStubs, + versionsFieldNameStub, + versionsStubs, +} from './__mocks__/componentsVersionsStubs'; +import { render, fireEvent, screen } from '@testing-library/react'; + +jest.mock('app/core/app_events'); +jest.mock('./ManageComponentsVersions.hooks'); + +describe('ManageComponentsVersionsModal::', () => { + it('renders form with operator, component and versions field with correct values', () => { + render( + {}} + isVisible + selectedKubernetes={kubernetesStub[0]} + setVisible={jest.fn()} + /> + ); + + expect( + screen.getByTestId('kubernetes-operator').textContent?.includes(operatorsOptionsStubs[0].label) + ).toBeTruthy(); + expect( + screen.getByTestId('kubernetes-component').textContent?.includes(psmdbComponentOptionsStubs[0].label) + ).toBeTruthy(); + expect(screen.getByTestId(`${versionsFieldNameStub}-options`).children).toHaveLength(versionsStubs.length); + expect(screen.getByTestId('kubernetes-default-version')).toBeInTheDocument(); + }); + it('calls setVisible on cancel', () => { + const setVisible = jest.fn(); + render( + {}} + isVisible + selectedKubernetes={kubernetesStub[0]} + setVisible={setVisible} + /> + ); + + const btn = screen.getByTestId('kubernetes-components-versions-cancel'); + fireEvent.click(btn); + + expect(setVisible).toHaveBeenCalledWith(false); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.tsx b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.tsx new file mode 100644 index 0000000000000..09986d7f9fe74 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.tsx @@ -0,0 +1,212 @@ +import React, { FC } from 'react'; +import { Form, Field, FormRenderProps } from 'react-final-form'; +import { FormApi } from 'final-form'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { AppEvents, SelectableValue } from '@grafana/data'; +import { LoaderButton, Modal, logger } from '@percona/platform-core'; +import appEvents from 'app/core/app_events'; +import { SelectFieldAdapter } from 'app/percona/shared/components/Form/FieldAdapters/FieldAdapters'; +import { MultiCheckboxField } from 'app/percona/shared/components/Form/MultiCheckbox/MultiCheckboxField'; +import { + requiredVersions, + buildVersionsFieldName, + findRecommendedVersions, + getDefaultOptions, + defaultRequired, + buildDefaultFieldName, + parseDefaultVersionsOptions, +} from './ManageComponentsVersions.utils'; +import { Messages } from './ManageComponentsVersionsModal.messages'; +import { Databases } from 'app/percona/shared/core'; +import { Overlay } from 'app/percona/shared/components/Elements/Overlay/Overlay'; +import { + ManageComponentsVersionsModalProps, + ManageComponentsVersionsRenderProps, + ManageComponentVersionsFields, +} from './ManageComponentsVersionsModal.types'; +import { Operators } from '../../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { useOperatorsComponentsVersions } from './ManageComponentsVersions.hooks'; +import { KubernetesOperatorStatus } from '../OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { newDBClusterService } from '../../DBCluster/DBCluster.utils'; +import { DATABASE_OPERATORS } from '../../DBCluster/DBCluster.constants'; +import { getStyles } from './ManageComponentsVersionsModal.styles'; + +export const ManageComponentsVersionsModal: FC = ({ + selectedKubernetes, + isVisible, + setVisible, + setSelectedCluster, +}) => { + const styles = useStyles(getStyles); + const [ + initialValues, + operatorsOptions, + componentOptions, + possibleComponentOptions, + versionsOptions, + versionsFieldName, + defaultFieldName, + loadingComponents, + setComponentOptions, + setVersionsOptions, + setVersionsFieldName, + setDefaultFieldName, + ] = useOperatorsComponentsVersions(selectedKubernetes); + const onChangeComponent = (values: ManageComponentsVersionsRenderProps, change: FormApi['change']) => ( + component: SelectableValue + ) => { + const newValues = { ...values, [ManageComponentVersionsFields.component]: component }; + const name = buildVersionsFieldName(newValues) as string; + const defaultName = buildDefaultFieldName(newValues) as string; + const options = values[name]; + + setVersionsFieldName(name); + setVersionsOptions(options); + setDefaultFieldName(defaultName); + + change(ManageComponentVersionsFields.component, component); + change(defaultName, values[defaultName]); + }; + const onChangeOperator = (values: ManageComponentsVersionsRenderProps, change: FormApi['change']) => ( + operator: SelectableValue + ) => { + const newComponentOptions = possibleComponentOptions[operator.value as Operators] as SelectableValue[]; + const newValues = { + ...values, + [ManageComponentVersionsFields.operator]: operator, + [ManageComponentVersionsFields.component]: newComponentOptions[0], + }; + const name = buildVersionsFieldName(newValues) as string; + const defaultName = buildDefaultFieldName(newValues) as string; + const options = values[name]; + + setComponentOptions(newComponentOptions); + setVersionsFieldName(name); + setVersionsOptions(options); + setDefaultFieldName(defaultName); + + change(ManageComponentVersionsFields.component, newComponentOptions[0]); + change(ManageComponentVersionsFields.operator, operator); + change(defaultName, values[defaultName]); + }; + const onSubmit = async (values: ManageComponentsVersionsRenderProps) => { + const { operators, kubernetesClusterName } = selectedKubernetes; + const operatorsList = Object.entries(operators); + + try { + for (const [operator, { status }] of operatorsList) { + if (status === KubernetesOperatorStatus.ok) { + const service = newDBClusterService(DATABASE_OPERATORS[operator as Operators] as Databases); + + await service.setComponents(kubernetesClusterName, values); + } + } + + setVisible(false); + appEvents.emit(AppEvents.alertSuccess, [Messages.success]); + } catch (e) { + logger.error(e); + } finally { + setSelectedCluster(null); + } + }; + + return ( + setVisible(false)}> + +
    ) => { + const name = buildVersionsFieldName(values); + const defaultName = buildDefaultFieldName(values); + const defaultVersionOptions = getDefaultOptions(values); + const defaultVersion = defaultName ? values[defaultName] : undefined; + const showDefaultErrorOnBlur = !defaultName && defaultVersionOptions.length === 0; + const selectedVersions = (name ? values[name] : []) as SelectableValue[]; + const isDefaultDisabled = defaultVersion + ? selectedVersions.find(({ name, value }) => name === defaultVersion.name && !value) + : false; + + // clear default version when the version is disabled + if (defaultName && defaultVersion && isDefaultDisabled) { + form.change(defaultName, { + value: undefined, + label: undefined, + }); + } + + return ( + + <> + + + + + + + + {Messages.save} + + + + + ); + }} + /> +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types.ts new file mode 100644 index 0000000000000..5bcffb6a448d5 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/ManageComponentsVersionsModal.types.ts @@ -0,0 +1,39 @@ +import { SelectableValue } from '@grafana/data'; +import { Operators } from '../../DBCluster/AddDBClusterModal/DBClusterBasicOptions/DBClusterBasicOptions.types'; +import { Kubernetes } from '../Kubernetes.types'; + +export interface ManageComponentsVersionsModalProps { + selectedKubernetes: Kubernetes; + isVisible: boolean; + setVisible: (value: boolean) => void; + setSelectedCluster: (kubernetesCluster: Kubernetes | null) => void; +} + +export interface ManageComponentsVersionsRenderProps { + operator: SelectableValue; + component: SelectableValue; + // used for dynamically generated attributes + // based on operator and supported components + [key: string]: any; +} + +export interface PossibleComponentOptions { + [Operators.pxc]?: SelectableValue[]; + [Operators.psmdb]?: SelectableValue[]; +} + +export enum SupportedComponents { + pxc = 'pxc', + haproxy = 'haproxy', + mongod = 'mongod', +} + +export enum ManageComponentVersionsFields { + operator = 'operator', + component = 'component', +} + +export type SetComponentOptionsAction = (options: SelectableValue[]) => void; +export type SetVersionsOptionsAction = (options: SelectableValue[]) => void; +export type SetVersionsFieldNameAction = (name: string) => void; +export type SetDefaultFieldNameAction = (name: string) => void; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/__mocks__/ManageComponentsVersions.hooks.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/__mocks__/ManageComponentsVersions.hooks.ts new file mode 100644 index 0000000000000..8aabba5246e72 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/__mocks__/ManageComponentsVersions.hooks.ts @@ -0,0 +1,24 @@ +import { + initialValuesStubs, + operatorsOptionsStubs, + possibleComponentOptionsStubs, + psmdbComponentOptionsStubs, + versionsFieldNameStub, + versionsStubs, + defaultFieldNameStub, +} from './componentsVersionsStubs'; + +export const useOperatorsComponentsVersions = () => [ + initialValuesStubs, + operatorsOptionsStubs, + psmdbComponentOptionsStubs, + possibleComponentOptionsStubs, + versionsStubs, + versionsFieldNameStub, + defaultFieldNameStub, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), +]; diff --git a/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/__mocks__/componentsVersionsStubs.ts b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/__mocks__/componentsVersionsStubs.ts new file mode 100644 index 0000000000000..2328fbf4203df --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ManageComponentsVersionsModal/__mocks__/componentsVersionsStubs.ts @@ -0,0 +1,40 @@ +import { DEFAULT_SUFFIX } from '../ManageComponentsVersionsModal.constants'; + +export const versionsStubs = [ + { name: 'v1.0', value: true, label: '1.0', status: 'available', default: false }, + { name: 'v2.0', value: true, label: '2.0', status: 'recommended', default: true }, +]; + +export const initialValuesStubs = { + operator: { name: 'psmdb', value: 'psmdb', label: 'Percona Operator for MongoDB 1' }, + component: { name: 'mongod', value: 'mongod', label: 'Percona Operator for MongoDB' }, + psmdbmongod: versionsStubs, + pxcpxc: versionsStubs, + pxchaproxy: versionsStubs, + psmdbmongoddefault: versionsStubs[1], + pxchaproxydefault: versionsStubs[1], + pxcpxcdefault: versionsStubs[1], +}; + +// use to omit default labels form testing +// due to parsing the label to a component to show the recommended option +export const omitDefaultLabels = ['psmdbmongoddefault.label', 'pxchaproxydefault.label', 'pxcpxcdefault.label']; + +export const possibleComponentOptionsStubs = { + psmdb: [{ name: 'mongod', value: 'mongod', label: 'Percona Operator for MongoDB' }], + pxc: [ + { name: 'pxc', value: 'pxc', label: 'Percona Operator for MySQL' }, + { name: 'haproxy', value: 'haproxy', label: 'HAProxy' }, + ], +}; + +export const operatorsOptionsStubs = [ + { name: 'psmdb', value: 'psmdb', label: 'Percona Operator for MongoDB 1' }, + { name: 'pxc', value: 'pxc', label: 'Percona Operator for MySQL 1' }, +]; + +export const psmdbComponentOptionsStubs = [{ name: 'mongod', value: 'mongod', label: 'Percona Operator for MongoDB' }]; + +export const versionsFieldNameStub = 'psmdbmongod'; + +export const defaultFieldNameStub = `${versionsFieldNameStub}${DEFAULT_SUFFIX}`; diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.styles.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.styles.ts new file mode 100644 index 0000000000000..d97e349811088 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.styles.ts @@ -0,0 +1,15 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + clusterStatusWrapper: css` + align-items: center; + display: flex; + justify-content: center; + padding: ${spacing.xs} 0; + + a > span { + cursor: pointer; + } + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.test.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.test.tsx new file mode 100644 index 0000000000000..9ca470fbc1293 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Databases } from 'app/percona/shared/core'; +import { KubernetesOperatorStatus as Status } from './KubernetesOperatorStatus.types'; +import { KubernetesOperatorStatus } from './KubernetesOperatorStatus'; +import { kubernetesStub } from '../../__mocks__/kubernetesStubs'; +import { render, screen } from '@testing-library/react'; + +describe('KubernetesOperatorStatus::', () => { + it('renders installation link when unavailable', () => { + render( + + ); + + expect(screen.getByTestId('cluster-link')).toBeInTheDocument(); + }); + + it("doesn't render link when installed", () => { + render( + + ); + expect(screen.queryByTestId('cluster-link')).not.toBeInTheDocument(); + }); + + it('renders link when available new version is available', () => { + render( + + ); + + expect(screen.getByTestId('cluster-link')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.tsx new file mode 100644 index 0000000000000..cb6de27436f08 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.tsx @@ -0,0 +1,55 @@ +/* eslint-disable react/display-name */ +import React, { FC } from 'react'; +import { Button, useStyles } from '@grafana/ui'; +import { OPERATOR_LABELS } from 'app/percona/shared/core'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { getStyles } from './KubernetesOperatorStatus.styles'; +import { KubernetesOperatorStatus as Status, KubernetesOperatorStatusProps } from './KubernetesOperatorStatus.types'; +import { getStatusLink } from './KubernetesOperatorStatus.utils'; +import { OperatorStatus } from './OperatorStatus/OperatorStatus'; +import { DATABASE_COMPONENT_TO_UPDATE_MAP } from '../../Kubernetes.constants'; + +export const KubernetesOperatorStatus: FC = ({ + operator, + databaseType, + kubernetes, + setSelectedCluster, + setOperatorToUpdate, + setUpdateOperatorModalVisible, +}) => { + const styles = useStyles(getStyles); + const { status, availableVersion } = operator; + const isVersionAvailable = (status === Status.ok || status === Status.unsupported) && !!availableVersion; + const showLink = status === Status.unavailable || isVersionAvailable; + const updateOperator = () => { + setSelectedCluster(kubernetes); + setOperatorToUpdate({ + operatorType: DATABASE_COMPONENT_TO_UPDATE_MAP[databaseType], + operatorTypeLabel: OPERATOR_LABELS[databaseType], + ...operator, + }); + setUpdateOperatorModalVisible(true); + }; + + return ( +
    + {showLink ? ( + + + + ) : ( + + )} + {isVersionAvailable && ( + + )} +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types.ts new file mode 100644 index 0000000000000..7402176b57ca8 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types.ts @@ -0,0 +1,18 @@ +import { Databases } from 'app/percona/shared/core'; +import { Kubernetes, Operator, OperatorToUpdate } from '../../Kubernetes.types'; + +export enum KubernetesOperatorStatus { + ok = 'OPERATORS_STATUS_OK', + invalid = 'OPERATORS_STATUS_INVALID', + unsupported = 'OPERATORS_STATUS_UNSUPPORTED', + unavailable = 'OPERATORS_STATUS_NOT_INSTALLED', +} + +export interface KubernetesOperatorStatusProps { + operator: Operator; + databaseType: Databases; + kubernetes: Kubernetes; + setSelectedCluster: (kubernetes: Kubernetes) => void; + setOperatorToUpdate: (operator: OperatorToUpdate) => void; + setUpdateOperatorModalVisible: (isVisible: boolean) => void; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.utils.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.utils.ts new file mode 100644 index 0000000000000..1fecb4458ee8d --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.utils.ts @@ -0,0 +1,12 @@ +import { Databases } from 'app/percona/shared/core'; +import { Kubernetes } from '../../Kubernetes.types'; +import { OPERATORS_DOCS_URL, OPERATORS_RN_URL, VERSION_PLACEHOLDER } from './OperatorStatus/OperatorStatus.constants'; +import { KubernetesOperatorStatus } from './KubernetesOperatorStatus.types'; + +export const hasActiveOperator = (kubernetes: Kubernetes) => + Object.values(kubernetes.operators).filter(({ status }) => status === KubernetesOperatorStatus.ok).length > 0; + +export const getStatusLink = (status: KubernetesOperatorStatus, databaseType: Databases, version?: string) => + status === KubernetesOperatorStatus.unavailable + ? OPERATORS_DOCS_URL[databaseType] + : OPERATORS_RN_URL[databaseType].replace(VERSION_PLACEHOLDER, version || ''); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.constants.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.constants.ts new file mode 100644 index 0000000000000..f2610163c2a8d --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.constants.ts @@ -0,0 +1,29 @@ +import { Databases } from 'app/percona/shared/core'; +import { KubernetesOperatorStatus } from '../KubernetesOperatorStatus.types'; + +export const OPERATORS_DOCS_URL = { + [Databases.mysql]: 'https://per.co.na/x0wBC4', + [Databases.mongodb]: 'https://per.co.na/03Clok', + [Databases.postgresql]: '', + [Databases.proxysql]: '', + [Databases.mariadb]: '', + [Databases.haproxy]: '', +}; + +export const VERSION_PLACEHOLDER = ''; + +export const OPERATORS_RN_URL = { + [Databases.mysql]: `https://www.percona.com/doc/kubernetes-operator-for-pxc/ReleaseNotes/Kubernetes-Operator-for-PXC-RN${VERSION_PLACEHOLDER}.html`, + [Databases.mongodb]: `https://www.percona.com/doc/kubernetes-operator-for-psmongodb/RN/Kubernetes-Operator-for-PSMONGODB-RN${VERSION_PLACEHOLDER}.html`, + [Databases.postgresql]: '', + [Databases.proxysql]: '', + [Databases.mariadb]: '', + [Databases.haproxy]: '', +}; + +export const STATUS_DATA_QA = { + [KubernetesOperatorStatus.invalid]: 'invalid', + [KubernetesOperatorStatus.ok]: 'ok', + [KubernetesOperatorStatus.unsupported]: 'unsupported', + [KubernetesOperatorStatus.unavailable]: 'unavailable', +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.styles.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.styles.ts new file mode 100644 index 0000000000000..ca6ee569fbe38 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.styles.ts @@ -0,0 +1,41 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ colors, spacing, typography, v1 }: GrafanaTheme2) => ({ + status: css` + background-color: ${v1.palette.gray1}; + border-radius: 20px; + color: ${v1.palette.gray85}; + cursor: default; + font-size: ${typography.size.sm}; + padding: 3px 15px; + display: flex; + `, + statusActive: css` + background-color: ${v1.palette.brandSuccess}; + label: active; + `, + statusVersionAvailable: css` + background-color: ${v1.palette.brandWarning}; + label: versionAvailable; + `, + statusFailed: css` + background-color: ${v1.palette.brandDanger}; + label: failed; + `, + statusUnsupported: css` + background-color: ${v1.palette.gray1}; + label: unsupported; + `, + statusUnavailable: css` + background-color: ${colors.primary.main}; + label: unavailable; + `, + installLinkIcon: css` + margin-left: ${v1.spacing.xs}; + `, + versionAvailable: css` + font-size: ${typography.size.sm}; + margin-left: ${v1.spacing.xs}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.test.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.test.tsx new file mode 100644 index 0000000000000..5c5ad89ab3171 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { KubernetesOperatorStatus as Status } from '../KubernetesOperatorStatus.types'; +import { OperatorStatus } from './OperatorStatus'; +import { render, screen } from '@testing-library/react'; + +describe('OperatorStatus::', () => { + it('renders correctly when active', () => { + render(); + + expect(screen.getByTestId('cluster-status-ok')).toBeInTheDocument(); + expect(screen.queryByTestId('operator-version-available')).not.toBeInTheDocument(); + }); + + it('renders available version when new version available and operator is installed', () => { + const operator = { status: Status.ok, availableVersion: '1.8.0' }; + render(); + + expect(screen.getByTestId('operator-version-available')).toBeInTheDocument(); + }); + + it("doesn't render available version when operator is unavailable", () => { + const operator = { status: Status.unavailable, availableVersion: '1.8.0' }; + render(); + expect(screen.queryByTestId('operator-version-available')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.tsx new file mode 100644 index 0000000000000..ee32380aedae5 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.tsx @@ -0,0 +1,48 @@ +import React, { FC, useMemo } from 'react'; +import { cx } from '@emotion/css'; +import { useStyles2, Icon } from '@grafana/ui'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { getStyles } from './OperatorStatus.styles'; +import { KubernetesOperatorStatus as Status } from '../KubernetesOperatorStatus.types'; +import { STATUS_DATA_QA } from './OperatorStatus.constants'; +import { OperatorStatusProps } from './OperatorStatus.types'; + +const { operatorStatus } = Messages.kubernetes; + +export const OperatorStatus: FC = ({ operator }) => { + const styles = useStyles2(getStyles); + const { status, availableVersion } = operator; + const showVersionAvailable = (status === Status.ok || status === Status.unsupported) && !!availableVersion; + const statusStyles = useMemo( + () => ({ + [styles.statusActive]: status === Status.ok, + [styles.statusVersionAvailable]: showVersionAvailable, + [styles.statusFailed]: status === Status.invalid, + [styles.statusUnsupported]: status === Status.unsupported, + [styles.statusUnavailable]: status === Status.unavailable, + }), + [ + status, + showVersionAvailable, + styles.statusActive, + styles.statusVersionAvailable, + styles.statusFailed, + styles.statusUnsupported, + styles.statusUnavailable, + ] + ); + + return ( + + {operatorStatus[status]} + {showVersionAvailable && ( + + {operatorStatus.getNewVersionAvailable(availableVersion)} + + )} + {(status === Status.unavailable || showVersionAvailable) && ( + + )} + + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.types.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.types.ts new file mode 100644 index 0000000000000..7b6d891c09462 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/OperatorStatus/OperatorStatus.types.ts @@ -0,0 +1,5 @@ +import { Operator } from '../../../Kubernetes.types'; + +export interface OperatorStatusProps { + operator: Operator; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.styles.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.styles.ts new file mode 100644 index 0000000000000..f73f1acbf34a0 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ palette, spacing }: GrafanaTheme) => ({ + modalWrapper: css` + div[data-testid='modal-body'] { + max-width: none; + } + `, + updateModalContent: css` + margin-bottom: ${spacing.xl}; + `, + versionHighlight: css` + color: ${palette.warn}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.test.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.test.tsx new file mode 100644 index 0000000000000..b8bbdced9ae3e --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UpdateOperatorModal } from './UpdateOperatorModal'; +import { KubernetesOperatorStatus } from '../KubernetesOperatorStatus.types'; +import { ComponentToUpdate } from '../../../Kubernetes.types'; + +jest.mock('app/percona/dbaas/components/Kubernetes/Kubernetes.service'); + +describe('UpdateOperatorModal::', () => { + const operator = { + status: KubernetesOperatorStatus.ok, + version: '1.7.0', + availableVersion: '1.8.0', + operatorType: ComponentToUpdate.pxc, + operatorTypeLabel: 'PXC', + }; + + it('should render message with new operator version', () => { + render( + + + + ); + const message = 'PXC 1.7.0 to version 1.8.0 in test_cluster'; + + expect(screen.getByTestId('update-operator-message')).toHaveTextContent(message); + }); + + it('should clear selected clsuter and operator on close', async () => { + const setVisible = jest.fn(); + const setSelectedCluster = jest.fn(); + const setOperatorToUpdate = jest.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('confirm-update-operator-button')); + + expect(setVisible).toHaveBeenCalledWith(false); + expect(setSelectedCluster).toHaveBeenCalledWith(null); + expect(setOperatorToUpdate).toHaveBeenCalledWith(null); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.tsx new file mode 100644 index 0000000000000..70f91eae813ac --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.tsx @@ -0,0 +1,68 @@ +import React, { FC, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { Modal, logger } from '@percona/platform-core'; +import { instalKuberneteslOperatorAction } from 'app/percona/shared/core/reducers'; +import { Messages } from 'app/percona/dbaas/DBaaS.messages'; +import { UpdateOperatorModalProps } from './UpdateOperatorModal.types'; +import { getStyles } from './UpdateOperatorModal.styles'; + +const { title, confirm, cancel, buildUpdateOperatorMessage } = Messages.kubernetes.updateOperatorModal; + +export const UpdateOperatorModal: FC = ({ + kubernetesClusterName, + selectedOperator, + isVisible, + setVisible, + setSelectedCluster, + setOperatorToUpdate, +}) => { + const styles = useStyles(getStyles); + const dispatch = useDispatch(); + const { operatorType, operatorTypeLabel, version, availableVersion } = selectedOperator; + + const onClose = useCallback(() => { + setVisible(false); + setSelectedCluster(null); + setOperatorToUpdate(null); + }, [setVisible, setSelectedCluster, setOperatorToUpdate]); + + const updateOperator = useCallback(async () => { + try { + onClose(); + dispatch( + instalKuberneteslOperatorAction({ + kubernetesClusterName, + operatorType, + availableVersion: availableVersion || '', + }) + ); + } catch (e) { + logger.error(e); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kubernetesClusterName, selectedOperator]); + + return ( +
    + +

    + {buildUpdateOperatorMessage( + operatorTypeLabel, + {availableVersion}, + {kubernetesClusterName}, + version + )} +

    + + + + +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.types.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.types.ts new file mode 100644 index 0000000000000..234ca16c4eb08 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/KubernetesOperatorStatus/UpdateOperatorModal/UpdateOperatorModal.types.ts @@ -0,0 +1,10 @@ +import { Kubernetes, OperatorToUpdate } from '../../../Kubernetes.types'; + +export interface UpdateOperatorModalProps { + kubernetesClusterName: string; + selectedOperator: OperatorToUpdate; + isVisible: boolean; + setVisible: (value: boolean) => void; + setSelectedCluster: (kubernetes: Kubernetes | null) => void; + setOperatorToUpdate: (operator: OperatorToUpdate | null) => void; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.styles.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.styles.ts new file mode 100644 index 0000000000000..c59b0c8051e57 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.styles.ts @@ -0,0 +1,16 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing, typography }: GrafanaTheme) => ({ + connectionItemWrapper: css` + display: flex; + align-items: center; + margin-bottom: ${spacing.xxs}; + `, + connectionItemLabel: css` + font-weight: ${typography.weight.bold}; + `, + connectionItemValue: css` + margin-left: ${spacing.sm}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.test.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.test.tsx new file mode 100644 index 0000000000000..d87b8452aab5c --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { OperatorStatusItem } from './OperatorStatusItem'; +import { Databases } from 'app/percona/shared/core'; +import { KubernetesOperatorStatus } from './KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { kubernetesStub } from '../__mocks__/kubernetesStubs'; +import { render } from '@testing-library/react'; + +describe('OperatorStatusItem::', () => { + it('renders', () => { + const { container } = render( + + ); + + expect(container).toHaveTextContent('Percona Operator for MySQL'); + expect(container).toHaveTextContent('Installed'); + expect(container).not.toHaveTextContent('1.8.0'); + }); + it('renders with operator version', () => { + const operator = { status: KubernetesOperatorStatus.ok, version: '1.8.0' }; + const { container } = render( + + ); + + expect(container).toHaveTextContent('Percona Operator for MySQL'); + expect(container).toHaveTextContent('Installed'); + expect(container).toHaveTextContent('1.8.0'); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.tsx b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.tsx new file mode 100644 index 0000000000000..e73c3dbb42675 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { useStyles } from '@grafana/ui'; +import { OPERATOR_FULL_LABELS } from 'app/percona/shared/core'; +import { getStyles } from './OperatorStatusItem.styles'; +import { OperatorStatusItemProps } from './OperatorStatusItem.types'; +import { KubernetesOperatorStatus } from './KubernetesOperatorStatus/KubernetesOperatorStatus'; +import { buildOperatorLabel } from './OperatorStatusItem.utils'; + +export const OperatorStatusItem: FC = ({ + operator, + databaseType, + kubernetes, + setSelectedCluster, + setOperatorToUpdate, + setUpdateOperatorModalVisible, + dataTestId, +}) => { + const styles = useStyles(getStyles); + + return ( +
    + + {buildOperatorLabel(operator, databaseType)}: + + + + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.types.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.types.ts new file mode 100644 index 0000000000000..a06557c3acfdd --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.types.ts @@ -0,0 +1,12 @@ +import { Databases } from 'app/percona/shared/core'; +import { Kubernetes, Operator, OperatorToUpdate } from '../Kubernetes.types'; + +export interface OperatorStatusItemProps { + dataTestId?: string; + databaseType: Databases; + operator: Operator; + kubernetes: Kubernetes; + setSelectedCluster: (kubernetes: Kubernetes) => void; + setOperatorToUpdate: (operator: OperatorToUpdate) => void; + setUpdateOperatorModalVisible: (isVisible: boolean) => void; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.utils.test.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.utils.test.ts new file mode 100644 index 0000000000000..cc757262b3ea4 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.utils.test.ts @@ -0,0 +1,21 @@ +import { Databases, OPERATOR_LABELS } from 'app/percona/shared/core'; +import { KubernetesOperatorStatus } from './KubernetesOperatorStatus/KubernetesOperatorStatus.types'; +import { buildOperatorLabel } from './OperatorStatusItem.utils'; + +describe('OperatorStatusItems.utils:: ', () => { + it('should return operator label with version', () => { + const operator = { + status: KubernetesOperatorStatus.ok, + version: '1.8.0', + }; + const expected = `${OPERATOR_LABELS[Databases.mysql]} 1.8.0`; + + expect(buildOperatorLabel(operator, Databases.mysql)).toEqual(expected); + }); + it('should return operator label without version', () => { + const operator = { status: KubernetesOperatorStatus.ok }; + const expected = OPERATOR_LABELS[Databases.mysql]; + + expect(buildOperatorLabel(operator, Databases.mysql)).toEqual(expected); + }); +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.utils.ts b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.utils.ts new file mode 100644 index 0000000000000..7ed8d9fd57c45 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/OperatorStatusItem/OperatorStatusItem.utils.ts @@ -0,0 +1,5 @@ +import { Databases, OPERATOR_LABELS } from 'app/percona/shared/core'; +import { Operator } from '../Kubernetes.types'; + +export const buildOperatorLabel = ({ version }: Operator, databaseType: Databases) => + version ? `${OPERATOR_LABELS[databaseType]} ${version}` : OPERATOR_LABELS[databaseType]; diff --git a/public/app/percona/dbaas/components/Kubernetes/PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage.styles.ts b/public/app/percona/dbaas/components/Kubernetes/PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage.styles.ts new file mode 100644 index 0000000000000..9e481dd932f7b --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage.styles.ts @@ -0,0 +1,17 @@ +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ v1: { spacing, palette } }: GrafanaTheme2) => ({ + link: css` + color: ${palette.gray4}; + padding-top: ${spacing.sm}; + text-decoration: underline; + &:hover { + color: white; + text-decoration: underline; + } + `, + alert: css` + margin-top: ${spacing.md}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage.tsx b/public/app/percona/dbaas/components/Kubernetes/PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage.tsx new file mode 100644 index 0000000000000..cbdedef06a684 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/PortalK8sFreeClusterPromotingMessage/PortalK8sFreeClusterPromotingMessage.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Alert, useStyles2 } from '@grafana/ui'; +import { getStyles } from './PortalK8sFreeClusterPromotingMessage.styles'; +import { getPerconaServer } from '../../../../shared/core/selectors'; +import { useSelector } from 'react-redux'; + +export const PortalK8sFreeClusterPromotingMessage = () => { + const styles = useStyles2(getStyles); + const { saasHost } = useSelector(getPerconaServer); + + return ( + +

    + Percona has a time-limited offer for testing DBaaS with a free k8s cluster. Please{' '} + + read more + {' '} + information about this offer +

    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.constants.ts b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.constants.ts new file mode 100644 index 0000000000000..2a3032d40af8f --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.constants.ts @@ -0,0 +1 @@ +export const GET_KUBERNETES_CONFIG_CANCEL_TOKEN = 'getKubernetesConfig'; diff --git a/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.messages.ts b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.messages.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.styles.ts b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.styles.ts new file mode 100644 index 0000000000000..4ff936b19c16b --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.styles.ts @@ -0,0 +1,14 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme) => ({ + deleteModalContent: css` + margin-bottom: ${theme.spacing.xl}; + `, + overlay: css` + height: 50vh; + overflow: scroll; + margin-top: ${theme.spacing.sm}; + margin-bottom: ${theme.spacing.sm}; + `, +}); diff --git a/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.tsx b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.tsx new file mode 100644 index 0000000000000..81b68c5557aa9 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.tsx @@ -0,0 +1,80 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { Button, ClipboardButton, HorizontalGroup, useTheme } from '@grafana/ui'; +import { AppEvents } from '@grafana/data'; +import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from 'app/percona/shared/helpers/api'; +import { appEvents } from 'app/core/app_events'; +import { Modal, logger } from '@percona/platform-core'; +import { Overlay } from 'app/percona/shared/components/Elements/Overlay/Overlay'; +import { ViewKubernetesClusterModalProps } from './ViewClusterConfigModal.types'; +import { KubernetesService } from '../Kubernetes.service'; +import { Messages } from '../../../DBaaS.messages'; +import { getStyles } from './ViewClusterConfigModal.styles'; +import { GET_KUBERNETES_CONFIG_CANCEL_TOKEN } from './ViewClusterConfigModal.constants'; + +export const ViewClusterConfigModal: FC = ({ + isVisible, + setVisible, + selectedCluster, +}) => { + const theme = useTheme(); + const styles = getStyles(theme); + + const [kubeconfig, setKubeconfig] = useState(''); + const [loading, setLoading] = useState(false); + const [generateToken] = useCancelToken(); + const outputRef = useRef(null); + + const copyToClipboard = useCallback(() => { + appEvents.emit(AppEvents.alertSuccess, [Messages.successfulCopyMessage]); + + return outputRef.current?.textContent || ''; + }, [outputRef]); + + useEffect(() => { + const getClusters = async () => { + if (!selectedCluster?.kubernetesClusterName) { + setVisible(false); + + return; + } + + setLoading(true); + try { + const config = await KubernetesService.getKubernetesConfig( + selectedCluster, + generateToken(GET_KUBERNETES_CONFIG_CANCEL_TOKEN) + ); + + setKubeconfig(config.kube_auth.kubeconfig); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setLoading(false); + }; + + getClusters(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCluster]); + + return ( + setVisible(false)}> + + + {Messages.copyToClipboard} + + + +
     (outputRef.current = pre)}>{kubeconfig}
    +
    + + + +
    + ); +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.types.ts b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.types.ts new file mode 100644 index 0000000000000..4736d69c96b17 --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/ViewClusterConfigModal/ViewClusterConfigModal.types.ts @@ -0,0 +1,7 @@ +import { Kubernetes } from '../Kubernetes.types'; + +export interface ViewKubernetesClusterModalProps { + selectedCluster?: Kubernetes; + isVisible: boolean; + setVisible: (value: boolean) => void; +} diff --git a/public/app/percona/dbaas/components/Kubernetes/__mocks__/Kubernetes.service.ts b/public/app/percona/dbaas/components/Kubernetes/__mocks__/Kubernetes.service.ts new file mode 100644 index 0000000000000..53502ce285f1e --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/__mocks__/Kubernetes.service.ts @@ -0,0 +1,39 @@ +import { CheckOperatorUpdateAPI, KubernetesListAPI } from '../Kubernetes.types'; +import { KubernetesClusterStatus } from '../KubernetesClusterStatus/KubernetesClusterStatus.types'; +import { KubernetesOperatorStatus } from '../OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; + +export const KubernetesService = { + getKubernetes: (): Promise => + Promise.resolve({ + kubernetes_clusters: [ + { + kubernetes_cluster_name: 'cluster_1', + status: KubernetesClusterStatus.ok, + operators: { psmdb: { status: KubernetesOperatorStatus.ok }, pxc: { status: KubernetesOperatorStatus.ok } }, + }, + { + kubernetes_cluster_name: 'cluster_2', + status: KubernetesClusterStatus.ok, + operators: { psmdb: { status: KubernetesOperatorStatus.ok }, pxc: { status: KubernetesOperatorStatus.ok } }, + }, + ], + }), + checkForOperatorUpdate: (): Promise => + Promise.resolve({ + cluster_to_components: { + cluster_1: { + component_to_update_information: { + 'psmdb-operator': { available_version: '1' }, + 'pxc-operator': { available_version: '1' }, + }, + }, + cluster_2: { + component_to_update_information: { + 'psmdb-operator': { available_version: '1' }, + 'pxc-operator': { available_version: '1' }, + }, + }, + }, + }), + installOperator: (): Promise => Promise.resolve(), +}; diff --git a/public/app/percona/dbaas/components/Kubernetes/__mocks__/kubernetesStubs.ts b/public/app/percona/dbaas/components/Kubernetes/__mocks__/kubernetesStubs.ts new file mode 100644 index 0000000000000..739733dffd2ab --- /dev/null +++ b/public/app/percona/dbaas/components/Kubernetes/__mocks__/kubernetesStubs.ts @@ -0,0 +1,47 @@ +import { KubernetesClusterStatus } from '../KubernetesClusterStatus/KubernetesClusterStatus.types'; +import { KubernetesOperatorStatus } from '../OperatorStatusItem/KubernetesOperatorStatus/KubernetesOperatorStatus.types'; + +export const kubernetesStub = [ + { + kubernetesClusterName: 'Cluster 1', + operators: { + psmdb: { + status: KubernetesOperatorStatus.ok, + }, + pxc: { + status: KubernetesOperatorStatus.ok, + }, + }, + status: KubernetesClusterStatus.ok, + }, + { + kubernetesClusterName: 'Cluster 2', + operators: { + psmdb: { + status: KubernetesOperatorStatus.ok, + }, + pxc: { + status: KubernetesOperatorStatus.ok, + }, + }, + status: KubernetesClusterStatus.ok, + }, +]; + +export const deleteActionStub = jest.fn(); +export const addActionStub = jest.fn(() => { + kubernetesStub.push({ + kubernetesClusterName: 'test', + operators: { + psmdb: { + status: KubernetesOperatorStatus.ok, + }, + pxc: { + status: KubernetesOperatorStatus.ok, + }, + }, + status: KubernetesClusterStatus.ok, + }); +}); +export const getActionStub = jest.fn(() => kubernetesStub); +export const setLoadingActionStub = jest.fn(); diff --git a/public/app/percona/dbaas/components/MultipleActions/MultipleActions.styles.ts b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.styles.ts new file mode 100644 index 0000000000000..01f47814db707 --- /dev/null +++ b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.styles.ts @@ -0,0 +1,14 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ colors }: GrafanaTheme) => ({ + disabledButton: css` + color: ${colors.formInputDisabledText}; + background-color: ${colors.dropdownBg}; + pointer-events: none; + + :hover { + background-color: ${colors.dropdownBg} !important; + } + `, +}); diff --git a/public/app/percona/dbaas/components/MultipleActions/MultipleActions.test.tsx b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.test.tsx new file mode 100644 index 0000000000000..e4125d7a62f77 --- /dev/null +++ b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { MultipleActions } from './MultipleActions'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; + +describe('MultipleActions::', () => { + it('renders correctly with actions', async () => { + const { container } = render( + + ); + + const btn = screen.getByTestId('dropdown-menu-toggle'); + await waitFor(() => fireEvent.click(btn)); + + expect(container.querySelectorAll('span')).toHaveLength(2); + }); + it('renders correctly disabled', () => { + render(); + + expect(screen.getByTestId('dropdown-menu-toggle')).toBeDisabled(); + }); +}); diff --git a/public/app/percona/dbaas/components/MultipleActions/MultipleActions.tsx b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.tsx new file mode 100644 index 0000000000000..c3827a83d60e1 --- /dev/null +++ b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.tsx @@ -0,0 +1,25 @@ +/* eslint-disable react/display-name */ +import React, { FC, Fragment } from 'react'; +import { IconButton, useTheme } from '@grafana/ui'; +import { Dropdown } from '@percona/platform-core'; +import { MultipleActionsProps } from './MultipleActions.types'; +import { getStyles } from './MultipleActions.styles'; + +export const MultipleActions: FC = ({ actions, disabled, dataTestId }) => { + const theme = useTheme(); + const styles = getStyles(theme); + + const Toggle = React.forwardRef((props, ref) => ( + + )); + + return ( + + {actions.map(({ title, action, disabled }) => ( + + {disabled ? {title} : {title}} + + ))} + + ); +}; diff --git a/public/app/percona/dbaas/components/MultipleActions/MultipleActions.types.ts b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.types.ts new file mode 100644 index 0000000000000..fa88048cb45df --- /dev/null +++ b/public/app/percona/dbaas/components/MultipleActions/MultipleActions.types.ts @@ -0,0 +1,11 @@ +export interface Action { + title: string; + action: () => void; + disabled?: boolean; +} + +export interface MultipleActionsProps { + actions: Action[]; + disabled?: boolean; + dataTestId?: string; +} diff --git a/public/app/percona/dbaas/components/MultipleActions/index.ts b/public/app/percona/dbaas/components/MultipleActions/index.ts new file mode 100644 index 0000000000000..412e42f20f121 --- /dev/null +++ b/public/app/percona/dbaas/components/MultipleActions/index.ts @@ -0,0 +1,2 @@ +export * from './MultipleActions'; +export * from './MultipleActions.types'; diff --git a/public/app/percona/dbaas/components/PMMServerURLWarning/PMMServerUrlWarning.tsx b/public/app/percona/dbaas/components/PMMServerURLWarning/PMMServerUrlWarning.tsx new file mode 100644 index 0000000000000..9f837cecfea43 --- /dev/null +++ b/public/app/percona/dbaas/components/PMMServerURLWarning/PMMServerUrlWarning.tsx @@ -0,0 +1,13 @@ +import React, { FC } from 'react'; +import { Alert } from '@grafana/ui'; +import { Link } from 'react-router-dom'; + +export const PMMServerUrlWarning: FC = () => ( + +

    + This will also set "Public Address" as {window.location.host}.

    + If you need to set if differently or edit later, use{' '} + Advanced Settings +

    +
    +); diff --git a/public/app/percona/dbaas/components/ProgressBar/ProgressBar.styles.ts b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.styles.ts new file mode 100644 index 0000000000000..cb3beb58491a9 --- /dev/null +++ b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.styles.ts @@ -0,0 +1,45 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ palette, spacing, typography }: GrafanaTheme) => ({ + progressBarWrapper: css` + display: flex; + flex-direction: column; + width: 100%; + `, + labelWrapper: css` + align-items: baseline; + display: flex; + justify-content: flex-start; + margin-bottom: ${spacing.xs}; + `, + stepsLabel: css` + color: ${palette.blue80}; + font-weight: ${typography.weight.bold}; + margin-right: ${spacing.sm}; + `, + stepsLabelError: css` + color: ${palette.orange}; + label: error; + `, + message: css` + font-size: ${typography.size.sm}; + `, + progressBarBackground: css` + background-color: ${palette.gray1}; + border-radius: 50px; + height: ${spacing.sm}; + width: 100%; + `, + getFillerStyles: (width: number) => css` + background-color: ${palette.blue80}; + border-radius: inherit; + height: 100%; + transition: width 300ms ease-in-out; + width: ${width}%; + `, + progressBarError: css` + background-color: ${palette.orange}; + label: error; + `, +}); diff --git a/public/app/percona/dbaas/components/ProgressBar/ProgressBar.test.tsx b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 0000000000000..62d109c992903 --- /dev/null +++ b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { ProgressBar } from './ProgressBar'; +import { ProgressBarStatus } from './ProgressBar.types'; +import { render, screen } from '@testing-library/react'; + +describe('ProgressBar::', () => { + it('renders with steps, message and width', () => { + render( + + ); + + expect(screen.getByTestId('progress-bar-steps')).toHaveTextContent('5/10'); + expect(screen.getByTestId('progress-bar-message')).toHaveTextContent('test message'); + expect(screen.getByTestId('progress-bar-content').className).not.toContain('error'); + expect(getComputedStyle(screen.getByTestId('progress-bar-content').children[0]).width).toEqual('50%'); + }); + + it('renders without message and rounds float width to nearest integer', () => { + render(); + + expect(screen.getByTestId('progress-bar-steps')).toHaveTextContent('4/7'); + expect(screen.getByTestId('progress-bar-message')).toHaveTextContent(''); + expect(getComputedStyle(screen.getByTestId('progress-bar-content').children[0]).width).toEqual('57%'); + }); + + it('renders with error status', () => { + render(); + + expect(screen.getByTestId('progress-bar-steps')).toHaveTextContent('0/1'); + expect(screen.getByTestId('progress-bar-message')).toHaveTextContent('test message'); + expect(getComputedStyle(screen.getByTestId('progress-bar-content').children[0]).width).toEqual('0%'); + expect(screen.getByTestId('progress-bar-content').children[0].className).toContain('error'); + }); + + it('handles invalid total steps', () => { + render(); + + expect(getComputedStyle(screen.getByTestId('progress-bar-content').children[0]).width).toEqual('0%'); + }); +}); diff --git a/public/app/percona/dbaas/components/ProgressBar/ProgressBar.tsx b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.tsx new file mode 100644 index 0000000000000..c839f20950287 --- /dev/null +++ b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.tsx @@ -0,0 +1,40 @@ +/* eslint-disable react/display-name */ +import React, { FC, useMemo } from 'react'; +import { cx } from '@emotion/css'; +import { useStyles } from '@grafana/ui'; +import { getStyles } from './ProgressBar.styles'; +import { ProgressBarProps, ProgressBarStatus } from './ProgressBar.types'; +import { getProgressBarPercentage } from './ProgressBar.utils'; + +export const ProgressBar: FC = ({ finishedSteps, totalSteps, status, message, dataTestId }) => { + const styles = useStyles(getStyles); + const progressBarErrorStyles = useMemo( + () => ({ + [styles.progressBarError]: status === ProgressBarStatus.error, + }), + [status, styles.progressBarError] + ); + const stepsLabelErrorStyles = useMemo( + () => ({ + [styles.stepsLabelError]: status === ProgressBarStatus.error, + }), + [status, styles.stepsLabelError] + ); + const width = getProgressBarPercentage(finishedSteps, totalSteps); + + return ( +
    +
    + + {finishedSteps === 0 && totalSteps === 0 ? '-/-' : `${finishedSteps}/${totalSteps}`} + + + {message} + +
    +
    +
    +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/ProgressBar/ProgressBar.types.ts b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.types.ts new file mode 100644 index 0000000000000..742f2748106d0 --- /dev/null +++ b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.types.ts @@ -0,0 +1,12 @@ +export enum ProgressBarStatus { + progress = 'PROGRESS', + error = 'ERROR', +} + +export interface ProgressBarProps { + totalSteps: number; + finishedSteps: number; + status: ProgressBarStatus; + message?: string; + dataTestId?: string; +} diff --git a/public/app/percona/dbaas/components/ProgressBar/ProgressBar.utils.test.ts b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.utils.test.ts new file mode 100644 index 0000000000000..77147796b66f7 --- /dev/null +++ b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.utils.test.ts @@ -0,0 +1,19 @@ +import { getProgressBarPercentage } from './ProgressBar.utils'; + +describe('ProgressBar.utils::', () => { + it('returns 0 when total steps is 0', () => { + expect(getProgressBarPercentage(10, 0)).toEqual(0); + }); + + it('returns 100 when finished steps are greater than total steps', () => { + expect(getProgressBarPercentage(6, 2)).toEqual(100); + }); + + it('returns correct percentage', () => { + expect(getProgressBarPercentage(10, 100)).toEqual(10); + }); + + it('returns correct percentage rounded', () => { + expect(getProgressBarPercentage(1, 7)).toEqual(14); + }); +}); diff --git a/public/app/percona/dbaas/components/ProgressBar/ProgressBar.utils.ts b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.utils.ts new file mode 100644 index 0000000000000..e24a00114aec6 --- /dev/null +++ b/public/app/percona/dbaas/components/ProgressBar/ProgressBar.utils.ts @@ -0,0 +1,11 @@ +export const getProgressBarPercentage = (finishedSteps: number, totalSteps: number) => { + if (totalSteps <= 0) { + return 0; + } + + if (finishedSteps > totalSteps) { + return 100; + } + + return Math.round((finishedSteps * 100) / totalSteps); +}; diff --git a/public/app/percona/dbaas/components/StepProgress/Step/Step.styles.ts b/public/app/percona/dbaas/components/StepProgress/Step/Step.styles.ts new file mode 100644 index 0000000000000..36c0c43e8842b --- /dev/null +++ b/public/app/percona/dbaas/components/StepProgress/Step/Step.styles.ts @@ -0,0 +1,83 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ border, colors, palette, spacing, isLight }: GrafanaTheme) => { + const stepHeaderHoverBg = isLight ? palette.gray95 : colors.dropdownOptionHoverBg; + const verticalLineColor = isLight ? palette.gray4 : palette.gray33; + + return { + step: css` + display: flex; + flex-direction: column; + `, + stepHeader: css` + align-items: center; + cursor: pointer; + display: flex; + padding: ${spacing.lg}; + transition: 0.3s; + &:hover { + background-color: ${stepHeaderHoverBg}; + } + `, + stepDisabled: css` + opacity: 0.6; + pointer-events: none; + `, + stepCircle: css` + align-items: center; + background-color: ${palette.gray2}; + border-radius: 50%; + color: ${palette.white}; + display: flex; + height: ${spacing.lg}; + justify-content: center; + margin-right: ${spacing.sm}; + transition: 50ms; + width: ${spacing.lg}; + `, + stepCircleDone: css` + background-color: ${palette.brandSuccess}; + `, + stepCircleCurrent: css` + background-color: ${palette.brandPrimary}; + `, + stepCircleInvalid: css` + background-color: ${palette.brandDanger}; + `, + stepContentWrapper: css` + margin-left: ${spacing.xl}; + position: relative; + `, + stepContentTransitionWrapper: css` + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-in-out; + `, + stepContentTransitionCurrent: (height: number) => css` + label: current; + max-height: ${height}px; + `, + stepContent: css` + padding: ${spacing.lg}; + padding-top: 0; + `, + stepVerticalLine: css` + &::before { + bottom: -${spacing.md}; + border-left-color: ${verticalLineColor}; + border-left-width: ${border.width.sm}; + border-left-style: solid; + content: ''; + height: 100%; + left: ${spacing.xs}; + min-height: ${spacing.xl}; + position: absolute; + top: -${spacing.md}; + } + `, + stepTitle: css` + color: ${colors.text}; + `, + }; +}; diff --git a/public/app/percona/dbaas/components/StepProgress/Step/Step.test.tsx b/public/app/percona/dbaas/components/StepProgress/Step/Step.test.tsx new file mode 100644 index 0000000000000..e154a06e38fb8 --- /dev/null +++ b/public/app/percona/dbaas/components/StepProgress/Step/Step.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Step, StepStatus } from './Step'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Icon } from '@grafana/ui'; + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + Icon: jest.fn(() =>
    ), +})); + +describe('Step::', () => { + it('renders step header correctly with title and number', () => { + render( + {}}> + Test content + + ); + + expect(screen.getByTestId('step-header').querySelectorAll('div')[0]).toHaveTextContent('2'); + expect(screen.getByTestId('step-header').querySelectorAll('div')[1]).toHaveTextContent('Test title'); + }); + + it('renders step header correctly without title and number', () => { + render( {}}>Test content); + + expect(screen.getByTestId('step-header').querySelectorAll('div')[0]).toHaveTextContent(''); + expect(screen.getByTestId('step-header').querySelectorAll('div')[1]).not.toHaveTextContent('Test title'); + }); + + it('renders checkmark when step is done', () => { + render( + {}}> + Test content + + ); + + expect(Icon).toHaveBeenCalledWith(expect.objectContaining({ name: 'check' }), expect.anything()); + }); + it('renders step content correctly', () => { + const { container } = render( {}}>Test content); + + expect(container.querySelectorAll('div')[6]).toHaveTextContent('Test content'); + }); + it('calls step action', () => { + const action = jest.fn(); + render(Test content); + const header = screen.getByTestId('step-header'); + + fireEvent.click(header); + expect(action).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/dbaas/components/StepProgress/Step/Step.tsx b/public/app/percona/dbaas/components/StepProgress/Step/Step.tsx new file mode 100644 index 0000000000000..9786fdaa2959f --- /dev/null +++ b/public/app/percona/dbaas/components/StepProgress/Step/Step.tsx @@ -0,0 +1,73 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { cx } from '@emotion/css'; +import { useStyles, Icon } from '@grafana/ui'; +import { getStyles } from './Step.styles'; + +export interface StepProps { + title?: string; + number?: number; + status?: StepStatus; + disabled?: boolean; + isLast?: boolean; + dataTestId?: string; + onClick: () => void; +} + +export enum StepStatus { + current = 'current', + done = 'done', + invalid = 'invalid', + todo = 'todo', +} + +export const Step: FC = ({ + children, + title, + number, + status = StepStatus.todo, + disabled, + isLast = false, + dataTestId, + onClick, +}) => { + const styles = useStyles(getStyles); + const stepCircleStatusStyles = { + [styles.stepCircleCurrent]: status === StepStatus.current, + [styles.stepCircleDone]: status === StepStatus.done, + [styles.stepCircleInvalid]: status === StepStatus.invalid, + }; + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(0); + + useEffect(() => { + const clientHeight = contentRef.current?.clientHeight as number; + + setContentHeight(status === StepStatus.current ? clientHeight : 0); + }, [status]); + + return ( +
    +
    +
    + {status === StepStatus.done ? : number} +
    +
    {title}
    +
    +
    +
    +
    + {children} +
    +
    +
    +
    + ); +}; diff --git a/public/app/percona/dbaas/components/StepProgress/StepProgress.styles.ts b/public/app/percona/dbaas/components/StepProgress/StepProgress.styles.ts new file mode 100644 index 0000000000000..a56185f7e14cd --- /dev/null +++ b/public/app/percona/dbaas/components/StepProgress/StepProgress.styles.ts @@ -0,0 +1,11 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + stepProgressWrapper: css` + width: 100%; + `, + createButton: css` + margin: ${spacing.md} ${spacing.xl} ${spacing.md} 0; + `, +}); diff --git a/public/app/percona/dbaas/components/StepProgress/StepProgress.test.tsx b/public/app/percona/dbaas/components/StepProgress/StepProgress.test.tsx new file mode 100644 index 0000000000000..7816f3d5e9f98 --- /dev/null +++ b/public/app/percona/dbaas/components/StepProgress/StepProgress.test.tsx @@ -0,0 +1,103 @@ +/* eslint-disable react/display-name */ +import React from 'react'; +import { LoaderButton, TextInputField, TextareaInputField } from '@percona/platform-core'; +import { StepProgress } from './StepProgress'; +import { render, fireEvent, screen } from '@testing-library/react'; + +describe('StepProgress::', () => { + const steps = [ + { + render: () => ( +
    + + +
    + ), + fields: ['name', 'email'], + dataTestId: 'step-1', + }, + { + render: () => ( +
    + + +
    + ), + fields: ['description'], + dataTestId: 'step-2', + }, + ]; + + const isCurrentStep = (dataTestId: string) => { + const stepContent = screen.getByTestId(`${dataTestId}`).querySelector('[data-testid="step-content"]'); + return stepContent ? stepContent.getElementsByTagName('div')[0].className.split('-')?.includes('current') : false; + }; + + it('renders steps correctly', () => { + render( {}} />); + const a = screen.queryAllByRole('textbox'); + expect(a.filter((item) => item.tagName === 'INPUT')).toHaveLength(2); + expect(a.filter((item) => item.tagName === 'TEXTAREA')).toHaveLength(1); + + expect(screen.getAllByRole('button')).toHaveLength(2); + expect(screen.getAllByTestId('step-header')).toHaveLength(2); + expect(isCurrentStep('step-1')).toBeTruthy(); + }); + + it('renders steps correctly with initial values', () => { + render( + {}} + initialValues={{ + name: 'Test name', + description: 'Test description', + }} + /> + ); + + expect(screen.getByTestId('name-text-input')).toHaveValue('Test name'); + expect(screen.getByTestId('description-textarea-input')).toHaveValue('Test description'); + }); + + it('changes current step correctly', () => { + render( {}} />); + + expect(isCurrentStep('step-1')).toBeTruthy(); + + const stepHeader = screen.getByTestId('step-2').querySelector('[data-testid="step-header"]'); + expect(stepHeader).toBeTruthy(); + if (stepHeader) { + fireEvent.click(stepHeader); + } + + expect(isCurrentStep('step-1')).toBeFalsy(); + expect(isCurrentStep('step-2')).toBeTruthy(); + }); + + it('calls submit correctly', () => { + const onSubmit = jest.fn(); + render( + + ); + + const email = screen.getByTestId('email-text-input'); + fireEvent.change(email, { target: { value: 'test@test.com' } }); + + const form = screen.getByTestId('step-progress'); + fireEvent.submit(form); + + expect(onSubmit).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/dbaas/components/StepProgress/StepProgress.tsx b/public/app/percona/dbaas/components/StepProgress/StepProgress.tsx new file mode 100644 index 0000000000000..b123ec84bfd80 --- /dev/null +++ b/public/app/percona/dbaas/components/StepProgress/StepProgress.tsx @@ -0,0 +1,111 @@ +import React, { FC, ReactNode, useCallback, useState } from 'react'; +import { Form, FormRenderProps } from 'react-final-form'; +import { FormApi } from 'final-form'; +import { HorizontalGroup, useStyles } from '@grafana/ui'; +import { LoaderButton } from '@percona/platform-core'; +import { Step, StepStatus } from './Step/Step'; +import { getStyles } from './StepProgress.styles'; +import { AddDBClusterFields } from '../DBCluster/AddDBClusterModal/AddDBClusterModal.types'; +import { generateUID } from '../DBCluster/AddDBClusterModal/AddDBClusterModal.utils'; + +export interface StepProgressProps { + steps: StepProps[]; + initialValues?: Record; + submitButtonMessage: string; + onSubmit: (values: Record) => void; + loading: boolean; +} + +export interface StepProps { + render: (props: FormRenderProps) => ReactNode; + title?: string; + fields: string[]; + dataTestId?: string; +} + +const getStepStatus = ( + form: FormApi, + fields: string[], + currentStep: number, + index: number, + stepsVisited: number[] +): StepStatus => { + if (currentStep === index) { + return StepStatus.current; + } + + const valid = fields.find((field) => form.getFieldState(field)?.invalid) === undefined; + const visited = stepsVisited.includes(index); + + if (visited) { + return valid ? StepStatus.done : StepStatus.invalid; + } + + return StepStatus.todo; +}; + +export const StepProgress: FC = ({ + steps, + initialValues, + submitButtonMessage, + onSubmit, + loading, +}) => { + const styles = useStyles(getStyles); + const [currentStep, setCurrentStep] = useState(0); + const [stepsVisited, setStepsVisited] = useState([currentStep]); + const onClick = useCallback( + (index: number) => () => { + setCurrentStep(index); + setStepsVisited([...stepsVisited, index]); + }, + [stepsVisited] + ); + + return ( +
    { + changeValue(state, `${AddDBClusterFields.name}`, () => `${databaseTypeValue}-${generateUID()}`); + }, + }} + render={({ form, handleSubmit, valid, pristine, ...props }) => ( + + {steps.map(({ render, title, fields, dataTestId }, index) => ( + + {render({ + form, + handleSubmit, + valid, + pristine, + ...props, + })} + + ))} + + + {submitButtonMessage} + + + + )} + /> + ); +}; diff --git a/public/app/percona/dbaas/components/Switch/Switch.styles.ts b/public/app/percona/dbaas/components/Switch/Switch.styles.ts new file mode 100644 index 0000000000000..56a3566e48486 --- /dev/null +++ b/public/app/percona/dbaas/components/Switch/Switch.styles.ts @@ -0,0 +1,37 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; + +export const getStyles = ({ spacing, palette, typography, colors }: GrafanaTheme) => ({ + field: css` + &:not(:last-child) { + margin-bottom: ${spacing.formInputMargin}; + } + `, + label: css` + display: block; + text-align: left; + font-size: ${typography.size.md}; + font-weight: ${typography.weight.semibold}; + line-height: 1.25; + margin: ${spacing.formLabelMargin}; + padding: ${spacing.formLabelPadding}; + color: ${colors.formLabel}; + `, + labelWrapper: css` + align-items: center; + display: flex; + flex-direction: row; + div[class$='-Icon'] { + display: flex; + margin-left: ${spacing.xs}; + } + `, + errorMessage: css` + color: ${palette.red}; + font-size: ${typography.size.sm}; + height: ${typography.size.sm}; + line-height: ${typography.lineHeight.sm}; + margin-top: ${spacing.sm}; + margin-bottom: ${spacing.xs}; + `, +}); diff --git a/public/app/percona/dbaas/components/Switch/Switch.test.tsx b/public/app/percona/dbaas/components/Switch/Switch.test.tsx new file mode 100644 index 0000000000000..bd168e3957033 --- /dev/null +++ b/public/app/percona/dbaas/components/Switch/Switch.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { FormWrapper } from '@percona/platform-core'; +import { SwitchField } from './Switch'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +describe('SwitchField::', () => { + it('should render an input element of type checkbox', async () => { + render( + + + + ); + + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + expect(screen.getByTestId('test-switch')).toBeInTheDocument(); + }); + + it('should call passed validators', () => { + const validatorOne = jest.fn(); + const validatorTwo = jest.fn(); + + render( + + + + ); + + expect(validatorOne).toBeCalledTimes(1); + expect(validatorTwo).toBeCalledTimes(1); + }); + + it('should show no labels if one is not specified', () => { + render( + + + + ); + + expect(screen.queryByTestId('test-field-label')).not.toBeInTheDocument(); + }); + + it('should show a label if one is specified', () => { + render( + + + + ); + + expect(screen.getByTestId('test-field-label')).toBeInTheDocument(); + expect(screen.getByTestId('test-field-label')).toHaveTextContent('test label'); + }); + + it('should change the state value when clicked', async () => { + render( + + + + ); + + expect(screen.getByTestId('test-switch')).toHaveProperty('value', 'on'); + + const checkbox = screen.getByRole('checkbox'); + await waitFor(() => fireEvent.change(checkbox, { target: { value: true } })); + + expect(screen.getByTestId('test-switch')).toHaveProperty('value', 'true'); + }); + + it('should disable switch when `disabled` is passed via props', () => { + render( + + + + ); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); +}); diff --git a/public/app/percona/dbaas/components/Switch/Switch.tsx b/public/app/percona/dbaas/components/Switch/Switch.tsx new file mode 100644 index 0000000000000..e6ad1b7da5123 --- /dev/null +++ b/public/app/percona/dbaas/components/Switch/Switch.tsx @@ -0,0 +1,48 @@ +import React, { FC, useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { cx } from '@emotion/css'; +import { Icon, Tooltip, Switch, useStyles } from '@grafana/ui'; +import { compose } from '@percona/platform-core/dist/shared/validators'; +import { SwitchFieldRenderProps, SwitchFieldProps } from './Switch.types'; +import { getStyles } from './Switch.styles'; + +export const SwitchField: FC = ({ + disabled, + fieldClassName, + inputProps, + label, + name, + validators, + tooltip, + tooltipIcon = 'info-circle', + ...fieldConfig +}) => { + const styles = useStyles(getStyles); + const inputId = `input-${name}-id`; + const validate = useMemo(() => (Array.isArray(validators) ? compose(validators) : undefined), [validators]); + + return ( + {...fieldConfig} type="checkbox" name={name} validate={validate}> + {({ input, meta }: SwitchFieldRenderProps) => ( +
    + {label && ( +
    + + {tooltip && ( + {tooltip}} data-testid={`${name}-field-tooltip`}> + + + )} +
    + )} + +
    + {meta.touched && meta.error} +
    +
    + )} + + ); +}; diff --git a/public/app/percona/dbaas/components/Switch/Switch.types.ts b/public/app/percona/dbaas/components/Switch/Switch.types.ts new file mode 100644 index 0000000000000..57dc46aba91cc --- /dev/null +++ b/public/app/percona/dbaas/components/Switch/Switch.types.ts @@ -0,0 +1,21 @@ +import { ReactNode } from 'react'; +import { FieldInputProps, FieldMetaState, UseFieldConfig } from 'react-final-form'; +import { IconName } from '@grafana/ui'; +import { FieldInputAttrs } from '@percona/platform-core/dist/shared/types'; +import { Validator } from '@percona/platform-core/dist/shared/validators'; + +export interface SwitchFieldRenderProps { + input: FieldInputProps; + meta: FieldMetaState; +} + +export interface SwitchFieldProps extends UseFieldConfig { + disabled?: boolean; + fieldClassName?: string; + inputProps?: FieldInputAttrs; + label?: string | ReactNode; + name: string; + validators?: Validator[]; + tooltip?: string; + tooltipIcon?: IconName; +} diff --git a/public/app/percona/entitlements/Entitlements.contants.ts b/public/app/percona/entitlements/Entitlements.contants.ts new file mode 100644 index 0000000000000..10c3841b34499 --- /dev/null +++ b/public/app/percona/entitlements/Entitlements.contants.ts @@ -0,0 +1 @@ +export const LIST_ENTITLEMENTS_CANCEL_TOKEN = 'listEntitlements'; diff --git a/public/app/percona/entitlements/Entitlements.messages.ts b/public/app/percona/entitlements/Entitlements.messages.ts new file mode 100644 index 0000000000000..7545faaa58b7d --- /dev/null +++ b/public/app/percona/entitlements/Entitlements.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + noData: 'No entitlements found', +}; diff --git a/public/app/percona/entitlements/Entitlements.service.ts b/public/app/percona/entitlements/Entitlements.service.ts new file mode 100644 index 0000000000000..93148d209fe5d --- /dev/null +++ b/public/app/percona/entitlements/Entitlements.service.ts @@ -0,0 +1,51 @@ +import { api } from 'app/percona/shared/helpers/api'; +import { CancelToken } from 'axios'; +import { EntitlementsResponse, Entitlement } from './Entitlements.types'; + +const BASE_URL = '/v1/Platform'; + +const EntitlementsService = { + async list(token?: CancelToken): Promise { + const { entitlements = [] } = await api.post( + `${BASE_URL}/SearchOrganizationEntitlements`, + {}, + false, + token + ); + return entitlements.map( + ({ + number, + name, + summary, + tier, + total_units, + unlimited_units, + support_level, + software_families, + start_date, + end_date, + platform, + }): Entitlement => { + const { security_advisor, config_advisor } = platform; + return { + number, + name, + summary, + tier, + totalUnits: total_units, + unlimitedUnits: unlimited_units, + supportLevel: support_level, + softwareFamilies: software_families, + startDate: new Date(start_date).toLocaleDateString('en-GB'), + endDate: new Date(end_date).toLocaleDateString('en-GB'), + platform: { + securityAdvisor: security_advisor, + configAdvisor: config_advisor, + }, + }; + } + ); + }, +}; + +export default EntitlementsService; diff --git a/public/app/percona/entitlements/Entitlements.styles.ts b/public/app/percona/entitlements/Entitlements.styles.ts new file mode 100644 index 0000000000000..22808e2309155 --- /dev/null +++ b/public/app/percona/entitlements/Entitlements.styles.ts @@ -0,0 +1,17 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { spacing } }: GrafanaTheme2) => ({ + loader: css` + display: flex; + justify-content: center; + `, + collapseWrapper: css` + margin-bottom: ${spacing.md}; + margin-top: ${spacing.md}; + `, + noDataLabel: css` + display: flex; + justify-content: center; + `, +}); diff --git a/public/app/percona/entitlements/Entitlements.test.tsx b/public/app/percona/entitlements/Entitlements.test.tsx new file mode 100644 index 0000000000000..3ac76946f278e --- /dev/null +++ b/public/app/percona/entitlements/Entitlements.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { StoreState } from 'app/types'; +import { configureStore } from 'app/store/configureStore'; +import { render, screen } from '@testing-library/react'; +import EntitlementsPage from './EntitlementsPage'; + +describe('EntitlementsPage', () => { + it('renders wrapper', async () => { + render( + + + + ); + expect(screen.getByTestId('page-wrapper-entitlements')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/entitlements/Entitlements.types.ts b/public/app/percona/entitlements/Entitlements.types.ts new file mode 100644 index 0000000000000..da464782d97a2 --- /dev/null +++ b/public/app/percona/entitlements/Entitlements.types.ts @@ -0,0 +1,41 @@ +export interface Entitlement { + number: string; + name: string; + summary: string; + tier: string; + totalUnits: string; + unlimitedUnits: boolean; + supportLevel: string; + softwareFamilies: string[]; + startDate: string; + endDate: string; + platform: Platform; +} + +export interface Platform { + securityAdvisor: boolean; + configAdvisor: boolean; +} + +interface RawEntitlement { + number: string; + name: string; + summary: string; + tier: string; + total_units: string; + unlimited_units: boolean; + support_level: string; + software_families: string[]; + start_date: string; + end_date: string; + platform: RawPlatform; +} + +interface RawPlatform { + security_advisor: boolean; + config_advisor: boolean; +} + +export interface EntitlementsResponse { + entitlements: RawEntitlement[]; +} diff --git a/public/app/percona/entitlements/EntitlementsPage.tsx b/public/app/percona/entitlements/EntitlementsPage.tsx new file mode 100644 index 0000000000000..8ea39c572e905 --- /dev/null +++ b/public/app/percona/entitlements/EntitlementsPage.tsx @@ -0,0 +1,77 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { useStyles2 } from '@grafana/ui'; +import { LIST_ENTITLEMENTS_CANCEL_TOKEN } from './Entitlements.contants'; +import { logger } from '@percona/platform-core'; +import Page from 'app/core/components/Page/Page'; +import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { useSelector } from 'react-redux'; +import { CollapsableSection } from '@grafana/ui/src/components'; +import { useCancelToken } from '../shared/components/hooks/cancelToken.hook'; +import { isApiCancelError } from '../shared/helpers/api'; +import { getStyles } from './Entitlements.styles'; +import EntitlementsService from './Entitlements.service'; +import { Entitlement } from './Entitlements.types'; +import { SectionContent } from './components/SectionContent/SectionContent'; +import { Label } from './components/SectionLabel/SectionLabel'; +import { PlatformConnectedLoader } from '../shared/components/Elements/PlatformConnectedLoader'; +import { StoreState } from 'app/types'; +import { Messages } from './Entitlements.messages'; +import { Overlay } from '../shared/components/Elements/Overlay/Overlay'; +import { PageContent } from './components/PageContent/PageContent'; + +const EntitlementsPage: FC = () => { + const [pendingRequest, setPendingRequest] = useState(true); + const [data, setData] = useState([]); + const isConnectedToPortal = useSelector((state: StoreState) => !!state.percona.user.isPlatformUser); + const [generateToken] = useCancelToken(); + const styles = useStyles2(getStyles); + const navModel = usePerconaNavModel('entitlements'); + + const getData = useCallback(async (showLoading = false) => { + showLoading && setPendingRequest(true); + + try { + const entitlements = await EntitlementsService.list(generateToken(LIST_ENTITLEMENTS_CANCEL_TOKEN)); + setData(entitlements); + } catch (e) { + if (isApiCancelError(e)) { + return; + } + logger.error(e); + } + setPendingRequest(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isConnectedToPortal === true) { + getData(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isConnectedToPortal]); + + return ( + + + + + 0} emptyMessage={Messages.noData} loading={pendingRequest}> + {data.map((entitlement: Entitlement) => { + const { number, name, endDate } = entitlement; + return ( +
    + } isOpen={false}> + + +
    + ); + })} +
    +
    +
    +
    +
    + ); +}; + +export default EntitlementsPage; diff --git a/public/app/percona/entitlements/components/Advisor/Advisor.styles.ts b/public/app/percona/entitlements/components/Advisor/Advisor.styles.ts new file mode 100644 index 0000000000000..cfc8e554181d9 --- /dev/null +++ b/public/app/percona/entitlements/components/Advisor/Advisor.styles.ts @@ -0,0 +1,19 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { palette } }: GrafanaTheme2) => ({ + tab: css` + padding-left: 16px; + `, + advisorWrapper: css` + display: flex; + justify-content: space-between; + width: 160px; + `, + checkIcon: css` + color: ${palette.greenBase}; + `, + timesIcon: css` + color: ${palette.redBase}; + `, +}); diff --git a/public/app/percona/entitlements/components/Advisor/Advisor.test.tsx b/public/app/percona/entitlements/components/Advisor/Advisor.test.tsx new file mode 100644 index 0000000000000..ddd4bd991237e --- /dev/null +++ b/public/app/percona/entitlements/components/Advisor/Advisor.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Advisor } from './Advisor'; + +describe('Advisor', () => { + test('renders Advisor with label and checked icon', async () => { + render(); + + expect(screen.getByTestId('advisor-check-icon')).toBeInTheDocument(); + expect(screen.getByText(/Test label/i)).toBeInTheDocument(); + }); + + test('renders Advisor with label and times icon', async () => { + render(); + + expect(screen.getByTestId('advisor-times-icon')).toBeInTheDocument(); + expect(screen.getByText(/Test label/i)).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/entitlements/components/Advisor/Advisor.tsx b/public/app/percona/entitlements/components/Advisor/Advisor.tsx new file mode 100644 index 0000000000000..d3514cf64965e --- /dev/null +++ b/public/app/percona/entitlements/components/Advisor/Advisor.tsx @@ -0,0 +1,18 @@ +import { Icon, useStyles2 } from '@grafana/ui'; +import React from 'react'; +import { getStyles } from './Advisor.styles'; +import { AdvisorProps } from './Advisor.types'; + +export const Advisor = ({ label, hasAdvisor }: AdvisorProps) => { + const styles = useStyles2(getStyles); + return ( +
    + {label}: + {hasAdvisor ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/public/app/percona/entitlements/components/Advisor/Advisor.types.ts b/public/app/percona/entitlements/components/Advisor/Advisor.types.ts new file mode 100644 index 0000000000000..58b64989fed80 --- /dev/null +++ b/public/app/percona/entitlements/components/Advisor/Advisor.types.ts @@ -0,0 +1,4 @@ +export interface AdvisorProps { + label: string; + hasAdvisor: boolean; +} diff --git a/public/app/percona/entitlements/components/PageContent/PageContent.test.tsx b/public/app/percona/entitlements/components/PageContent/PageContent.test.tsx new file mode 100644 index 0000000000000..240ef382df227 --- /dev/null +++ b/public/app/percona/entitlements/components/PageContent/PageContent.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { PageContent } from './PageContent'; +import React from 'react'; + +describe('PageContent', () => { + it('should display the noData section when no data is passed', async () => { + await render(); + expect(screen.getByTestId('page-no-data')).toBeInTheDocument(); + expect(screen.getByText('empty')).toBeInTheDocument(); + }); + + it('should not display the noData section when no data is passed and it is still loading', async () => { + render(); + expect(screen.getByTestId('page-no-data')).toBeInTheDocument(); + expect(screen.queryByText('empty')).not.toBeInTheDocument(); + }); + + it('should display the page when there is data', async () => { + const Dummy = () => ; + render( + + + + ); + + expect(screen.queryByTestId('page-no-data')).not.toBeInTheDocument(); + expect(screen.getByTestId('dummy')).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/entitlements/components/PageContent/PageContent.tsx b/public/app/percona/entitlements/components/PageContent/PageContent.tsx new file mode 100644 index 0000000000000..3192d00bc4684 --- /dev/null +++ b/public/app/percona/entitlements/components/PageContent/PageContent.tsx @@ -0,0 +1,6 @@ +import React, { FC } from 'react'; +import { EmptyBlock } from 'app/percona/shared/components/Elements/EmptyBlock'; +import { PageContentProps } from './PageContent.types'; + +export const PageContent: FC = ({ hasData, emptyMessage, loading, children }) => + hasData ? <>{children} : {!loading &&

    {emptyMessage}

    }
    ; diff --git a/public/app/percona/entitlements/components/PageContent/PageContent.types.ts b/public/app/percona/entitlements/components/PageContent/PageContent.types.ts new file mode 100644 index 0000000000000..5a934196c305a --- /dev/null +++ b/public/app/percona/entitlements/components/PageContent/PageContent.types.ts @@ -0,0 +1,5 @@ +export interface PageContentProps { + hasData: boolean; + emptyMessage: string; + loading?: boolean; +} diff --git a/public/app/percona/entitlements/components/SectionContent/SectionContent.messages.ts b/public/app/percona/entitlements/components/SectionContent/SectionContent.messages.ts new file mode 100644 index 0000000000000..ce28079507186 --- /dev/null +++ b/public/app/percona/entitlements/components/SectionContent/SectionContent.messages.ts @@ -0,0 +1,13 @@ +export const Messages = { + startDate: 'Start Date', + endDate: 'End Date', + summary: 'Summary', + tier: 'Tier', + totalUnits: 'Total Units', + unlimited: 'unlimited', + softwareFamilies: 'Software Families', + supportLevel: 'Support Level', + platform: 'Platform', + configAdvisor: 'Config Advisor', + securityAdvisor: 'Security Advisor', +}; diff --git a/public/app/percona/entitlements/components/SectionContent/SectionContent.test.tsx b/public/app/percona/entitlements/components/SectionContent/SectionContent.test.tsx new file mode 100644 index 0000000000000..3723e06d68ffb --- /dev/null +++ b/public/app/percona/entitlements/components/SectionContent/SectionContent.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SectionContent } from './SectionContent'; +import { Entitlement } from '../../Entitlements.types'; + +const entitlement: Entitlement = { + number: 'ENTLMT0001028', + name: 'Unmeasured MySQL Support - Premium - ENTLMT0001026', + summary: '3 MySQL Servers', + totalUnits: '3', + tier: 'Tier 1', + unlimitedUnits: false, + supportLevel: 'Customer', + softwareFamilies: ['MySQL', 'Oracle'], + startDate: '28/10/2019', + endDate: '08/02/2022', + platform: { + securityAdvisor: true, + configAdvisor: false, + }, +}; + +describe('Entitlements Content', () => { + test('renders SectionContent with correct data', async () => { + render(); + + expect(screen.getByText(/Tier 1/)).toBeInTheDocument(); + expect(screen.getByText(/3 MySQL Servers/)).toBeInTheDocument(); + expect(screen.getByText(/Customer/)).toBeInTheDocument(); + expect(screen.getByText(/28\/10\/2019/)).toBeInTheDocument(); + expect(screen.getByText(/08\/02\/2022/)).toBeInTheDocument(); + }); +}); diff --git a/public/app/percona/entitlements/components/SectionContent/SectionContent.tsx b/public/app/percona/entitlements/components/SectionContent/SectionContent.tsx new file mode 100644 index 0000000000000..f631df690c86b --- /dev/null +++ b/public/app/percona/entitlements/components/SectionContent/SectionContent.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { SectionContentProps } from './SectionContent.types'; +import { Messages } from './SectionContent.messages'; +import { Advisor } from '../Advisor/Advisor'; +export const SectionContent = ({ entitlement }: SectionContentProps) => { + const { + summary, + tier, + totalUnits, + supportLevel, + startDate, + endDate, + platform: { securityAdvisor, configAdvisor }, + } = entitlement; + + return ( +
    +

    + {Messages.startDate}: {startDate}
    + {Messages.endDate}: {endDate}
    +

    +

    + {Messages.summary}: {summary}
    + {Messages.tier}: {tier}
    + {Messages.totalUnits}: {totalUnits} +
    + {Messages.supportLevel}: {supportLevel}
    + {Messages.platform}: + + +

    +
    + ); +}; diff --git a/public/app/percona/entitlements/components/SectionContent/SectionContent.types.ts b/public/app/percona/entitlements/components/SectionContent/SectionContent.types.ts new file mode 100644 index 0000000000000..b1a0bd64adbf0 --- /dev/null +++ b/public/app/percona/entitlements/components/SectionContent/SectionContent.types.ts @@ -0,0 +1,5 @@ +import { Entitlement } from '../../Entitlements.types'; + +export interface SectionContentProps { + entitlement: Entitlement; +} diff --git a/public/app/percona/entitlements/components/SectionLabel/SectionLabel.messages.ts b/public/app/percona/entitlements/components/SectionLabel/SectionLabel.messages.ts new file mode 100644 index 0000000000000..29d07ead761fc --- /dev/null +++ b/public/app/percona/entitlements/components/SectionLabel/SectionLabel.messages.ts @@ -0,0 +1,3 @@ +export const Messages = { + expiryDate: 'Expiry Date', +}; diff --git a/public/app/percona/entitlements/components/SectionLabel/SectionLabel.styles.ts b/public/app/percona/entitlements/components/SectionLabel/SectionLabel.styles.ts new file mode 100644 index 0000000000000..773db876ae911 --- /dev/null +++ b/public/app/percona/entitlements/components/SectionLabel/SectionLabel.styles.ts @@ -0,0 +1,16 @@ +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ v1: { palette } }: GrafanaTheme2) => ({ + labelWrapper: css` + display: flex; + justify-content: space-between; + width: 100%; + `, + label: css` + display: flex; + align-items: center; + color: ${palette.blue85}; + font-size: 12px; + `, +}); diff --git a/public/app/percona/entitlements/components/SectionLabel/SectionLabel.test.tsx b/public/app/percona/entitlements/components/SectionLabel/SectionLabel.test.tsx new file mode 100644 index 0000000000000..beb4511c85f7c --- /dev/null +++ b/public/app/percona/entitlements/components/SectionLabel/SectionLabel.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Label } from './SectionLabel'; + +describe('SectionLabel', () => { + test('renders SectionLabel with expiry date', async () => { + render(
    + + + + ); +}; + +export default AlertRuleTemplate; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.types.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.types.ts new file mode 100644 index 0000000000000..2238b8ae5bfed --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.types.ts @@ -0,0 +1,117 @@ +import { Severity } from 'app/percona/shared/core'; + +export interface UploadAlertRuleTemplatePayload { + yaml: string; +} + +export interface UpdateAlertRuleTemplatePayload { + yaml: string; + name: string; +} + +export interface DeleteAlertRuleTemplatePayload { + name: string; +} + +export interface AlertRuleTemplateGetPayload { + page_params: { + page_size: number; + index: number; + }; +} + +interface AlertRuleTemplatesTotals { + total_items: number; + total_pages: number; +} + +export interface TemplatesListAPI { + templates: TemplateAPI[]; + totals: AlertRuleTemplatesTotals; +} + +export interface TemplatesList extends Omit { + templates: Template[]; +} + +export enum SourceDescription { + BUILT_IN = 'BUILT_IN', + SAAS = 'SAAS', + USER_FILE = 'USER_FILE', + USER_API = 'USER_API', +} + +// https://github.com/percona-platform/saas/blob/main/pkg/alert/type.go +export enum TemplateParamType { + FLOAT = 'FLOAT', + BOOL = 'BOOL', + STRING = 'STRING', +} + +// https://github.com/percona-platform/saas/blob/main/pkg/alert/unit.go +export enum TemplateParamUnit { + PERCENTAGE = 'PERCENTAGE', + SECONDS = 'SECONDS', +} + +export interface TemplateFloatParamAPI { + has_default: boolean; + has_min: boolean; + has_max: boolean; + default?: number; + min?: number; + max?: number; +} + +export interface TemplateFloatParam extends Omit { + hasDefault: boolean; + hasMin: boolean; + hasMax: boolean; +} + +export interface TemplateParamAPI { + name: string; + type: TemplateParamType; + unit: TemplateParamUnit; + summary: string; + float?: TemplateFloatParamAPI; +} + +export interface TemplateParam extends Omit { + float?: TemplateFloatParam; +} + +export interface TemplateAnnotation { + summary?: string; +} + +export interface TemplateAPI { + summary: string; + name: string; + source: SourceDescription; + created_at: string | undefined; + yaml: string; + params?: TemplateParamAPI[]; + expr: string; + annotations?: TemplateAnnotation; + severity: keyof typeof Severity; + for: string; +} + +export interface Template extends Omit { + params?: TemplateParam[]; +} + +export interface FormattedTemplate { + name: string; + summary: string; + source: SourceDescription; + created_at: string | undefined; + yaml: string; +} + +export interface AlertRuleTemplatesTableProps { + pendingRequest: boolean; + data: FormattedTemplate[]; + getAlertRuleTemplates: () => void; +} diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.utils.test.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.utils.test.ts new file mode 100644 index 0000000000000..072622a360caf --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.utils.test.ts @@ -0,0 +1,56 @@ +import { formatTemplate, formatTemplates, beautifyUnit, formatSource } from './AlertRuleTemplate.utils'; +import { SourceDescription, Template, TemplateParamUnit } from './AlertRuleTemplate.types'; +import { SOURCE_MAP } from './AlertRuleTemplate.constants'; + +const moment = jest.requireActual('moment-timezone'); +moment.tz.setDefault('UTC'); + +const testTemplate = { + source: SourceDescription.BUILT_IN, + summary: 'MySQL database down', + created_at: '2020-11-25T16:53:39.366Z', + yaml: 'yaml file content', +} as Template; + +const expectedTemplate = { + source: SourceDescription.BUILT_IN, + summary: 'MySQL database down', + created_at: '2020-11-25 16:53:39', + yaml: 'yaml file content', +}; + +describe('AlertRuleTemplatesTable utils', () => { + test('formatTemplate', () => { + expect(formatTemplate(testTemplate)).toEqual(expectedTemplate); + }); + + test('formatTemplate', () => { + expect(formatTemplates([testTemplate, testTemplate])).toEqual([expectedTemplate, expectedTemplate]); + }); + + test('formatTemplate with undefined creation date', () => { + const testTemplate = { + source: SourceDescription.BUILT_IN, + summary: 'MySQL database down', + yaml: 'yaml file content', + } as Template; + + const expectedTemplate = { + source: SourceDescription.BUILT_IN, + summary: 'MySQL database down', + yaml: 'yaml file content', + }; + + expect(formatTemplates([testTemplate, testTemplate])).toEqual([expectedTemplate, expectedTemplate]); + }); + + test('beautifyUnit', () => { + expect(beautifyUnit(TemplateParamUnit.PERCENTAGE)).toBe('%'); + expect(beautifyUnit(TemplateParamUnit.SECONDS)).toBe('seconds'); + }); + + test('formatSource', () => { + expect(formatSource(SourceDescription.BUILT_IN)).toBe(SOURCE_MAP.BUILT_IN); + expect(formatSource(SourceDescription.SAAS)).toBe(SOURCE_MAP.SAAS); + }); +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.utils.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.utils.ts new file mode 100644 index 0000000000000..4d2f259789443 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplate.utils.ts @@ -0,0 +1,20 @@ +import { SourceDescription, FormattedTemplate, Template, TemplateParamUnit } from './AlertRuleTemplate.types'; +import moment from 'moment/moment'; +import { UNIT_MAP, SOURCE_MAP } from './AlertRuleTemplate.constants'; + +export const formatTemplate = (template: Template): FormattedTemplate => { + const { summary, source, created_at, ...restProps } = template; + + return { + summary, + source, + created_at: created_at ? moment(created_at).format('YYYY-MM-DD HH:mm:ss') : undefined, + ...restProps, + }; +}; + +export const formatTemplates = (templates: Template[]): FormattedTemplate[] => templates.map(formatTemplate); + +export const beautifyUnit = (unit: TemplateParamUnit) => UNIT_MAP[unit]; + +export const formatSource = (source: SourceDescription) => SOURCE_MAP[source]; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.styles.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.styles.ts new file mode 100644 index 0000000000000..56aad76a811fd --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.styles.ts @@ -0,0 +1,9 @@ +import { css } from '@emotion/css'; + +export const getStyles = () => ({ + actionsWrapper: css` + display: flex; + justify-content: space-between; + align-items: center; + `, +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.test.tsx b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.test.tsx new file mode 100644 index 0000000000000..8439a00be894c --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { dataTestId } from '@percona/platform-core'; +import { AlertRuleTemplateActions } from './AlertRuleTemplateActions'; +import { formattedTemplateStubs } from '../__mocks__/alertRuleTemplateStubs'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('AlertRuleTemplateActions', () => { + it('should render component correctly', () => { + render(); + + expect(screen.queryByTestId(dataTestId('alert-rule-template-edit-button'))).toBeFalsy(); + expect(screen.getByTestId('edit-template-button')).toBeTruthy(); + }); + + it('should open edit modal when clicking edit button', () => { + render(); + const button = screen.getByTestId('edit-template-button'); + fireEvent.click(button); + expect(screen.findByTestId('alert-rule-template-edit-button')).toBeTruthy(); + }); + + it('should open delete modal when clicking delete button', () => { + render(); + + const button = screen.getByTestId('delete-template-button'); + fireEvent.click(button); + + expect(screen.findByTestId('confirm-delete-modal-button')).toBeTruthy(); + }); + + it('should disable edit and delete buttons when template is built-in', () => { + render(); + + const editButton = screen.getByTestId('edit-template-button'); + const deleteButton = screen.getByTestId('delete-template-button'); + + expect(editButton).toBeDisabled(); + expect(deleteButton).toBeDisabled(); + }); + + it('should disable edit and delete buttons when template is from a file', () => { + render(); + + const editButton = screen.getByTestId('edit-template-button'); + const deleteButton = screen.getByTestId('delete-template-button'); + + expect(editButton).toBeDisabled(); + expect(deleteButton).toBeDisabled(); + }); + + it('should disable edit and delete buttons when Portal is the template source', () => { + render(); + + const editButton = screen.getByTestId('edit-template-button'); + const deleteButton = screen.getByTestId('delete-template-button'); + + expect(editButton).toBeDisabled(); + expect(deleteButton).toBeDisabled(); + }); +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.tsx b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.tsx new file mode 100644 index 0000000000000..c961a35a1f10c --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.tsx @@ -0,0 +1,53 @@ +import React, { FC, useState, useMemo } from 'react'; +import { IconButton, Tooltip, useStyles } from '@grafana/ui'; +import { EditAlertRuleTemplateModal } from '../EditAlertRuleTemplateModal/EditAlertRuleTemplateModal'; +import { getStyles } from './AlertRuleTemplateActions.styles'; +import { AlertRuleTemplateActionsProps } from './AlertRuleTemplateActions.types'; +import { SourceDescription } from '../AlertRuleTemplate.types'; +import { DeleteRuleTemplateModal } from '../DeleteRuleTemplateModal/DeleteRuleTemplateModal'; + +const nonActionableSources = [SourceDescription.BUILT_IN, SourceDescription.USER_FILE, SourceDescription.SAAS]; + +export const AlertRuleTemplateActions: FC = ({ template, getAlertRuleTemplates }) => { + const styles = useStyles(getStyles); + const [editModalVisible, setEditModalVisible] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const { source, yaml, name, summary } = template; + const isActionDisabled = useMemo(() => nonActionableSources.includes(source), [source]); + + return ( +
    + + setEditModalVisible(true)} + /> + + + setDeleteModalVisible(true)} + /> + + + +
    + ); +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.types.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.types.ts new file mode 100644 index 0000000000000..60cd4edff630c --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/AlertRuleTemplateActions/AlertRuleTemplateActions.types.ts @@ -0,0 +1,6 @@ +import { FormattedTemplate } from '../AlertRuleTemplate.types'; + +export interface AlertRuleTemplateActionsProps { + template: FormattedTemplate; + getAlertRuleTemplates: () => void; +} diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.messages.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.messages.ts new file mode 100644 index 0000000000000..d1b21a65701ac --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + title: 'Delete Alert Rule Template', + getDeleteSuccess: (name: string) => `Alert rule template "${name}" successfully deleted.`, + getDeleteMessage: (name: string) => `Are you sure you want to delete the alert rule template "${name}"?`, +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.test.tsx b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.test.tsx new file mode 100644 index 0000000000000..947ee32be147e --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { DeleteRuleTemplateModal } from './DeleteRuleTemplateModal'; +import { templateStubs } from '../__mocks__/alertRuleTemplateStubs'; +import { render, screen, fireEvent } from '@testing-library/react'; + +jest.mock('../AlertRuleTemplate.service'); +jest.mock('app/core/app_events'); + +describe('DeleteRuleTemplateModal', () => { + it('should render delete modal', () => { + render( + + ); + + expect(screen.getByTestId('confirm-delete-modal-button')).toBeInTheDocument(); + expect(screen.getByTestId('cancel-delete-modal-button')).toBeInTheDocument(); + }); + + it('should not render modal when visible is set to false', () => { + render( + + ); + + expect(screen.queryByTestId('confirm-delete-modal-button')).not.toBeInTheDocument(); + }); + + it('should call setVisible on close', () => { + const setVisible = jest.fn(); + render( + + ); + + const background = screen.getByTestId('modal-background'); + fireEvent.click(background); + + expect(setVisible).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.tsx b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.tsx new file mode 100644 index 0000000000000..d4dfa67c9b3d3 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.tsx @@ -0,0 +1,44 @@ +import React, { FC, useState } from 'react'; +import { logger } from '@percona/platform-core'; +import { AppEvents } from '@grafana/data'; +import { appEvents } from 'app/core/core'; +import { DeleteModal } from 'app/percona/shared/components/Elements/DeleteModal'; +import { DeleteRuleTemplateModalProps } from './DeleteRuleTemplateModal.types'; +import { Messages } from './DeleteRuleTemplateModal.messages'; +import { AlertRuleTemplateService } from '../AlertRuleTemplate.service'; + +const { title, getDeleteMessage, getDeleteSuccess } = Messages; + +export const DeleteRuleTemplateModal: FC = ({ + template, + isVisible, + setVisible, + getAlertRuleTemplates, +}) => { + const [pending, setPending] = useState(false); + const { name, summary } = template || {}; + const onDelete = async () => { + try { + setPending(true); + await AlertRuleTemplateService.delete({ name }); + setVisible(false); + appEvents.emit(AppEvents.alertSuccess, [getDeleteSuccess(summary)]); + getAlertRuleTemplates(); + } catch (e) { + logger.error(e); + } finally { + setPending(false); + } + }; + + return ( + + ); +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.types.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.types.ts new file mode 100644 index 0000000000000..753b70ada40ea --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/DeleteRuleTemplateModal/DeleteRuleTemplateModal.types.ts @@ -0,0 +1,8 @@ +import { FormattedTemplate } from '../AlertRuleTemplate.types'; + +export interface DeleteRuleTemplateModalProps { + template: FormattedTemplate; + isVisible: boolean; + setVisible: (value: boolean) => void; + getAlertRuleTemplates: () => void; +} diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.constants.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.constants.ts new file mode 100644 index 0000000000000..be78a9403365d --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.constants.ts @@ -0,0 +1 @@ +export const MAX_TITLE_LENGTH = 35; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.messages.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.messages.ts new file mode 100644 index 0000000000000..f3050c463c451 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.messages.ts @@ -0,0 +1,8 @@ +export const Messages = { + alertRuleTemplateLabel: 'Alert Rule Template', + submitButton: 'Save', + cancelAction: 'Cancel', + editSuccess: 'Alert rule template successfully edited', + nameNotEditable: 'Name cannot be changed. If you need to change it, please create a new Template.', + getTitle: (name: string) => `Edit "${name}" Alert Rule Template`, +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.styles.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.styles.ts new file mode 100644 index 0000000000000..3fe46189c817d --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.styles.ts @@ -0,0 +1,16 @@ +import { GrafanaTheme } from '@grafana/data'; +import { css } from '@emotion/css'; + +export const getStyles = ({ spacing }: GrafanaTheme) => ({ + alertRuleTemplate: css` + min-height: 250px; + `, + field: css` + &:not(:last-child) { + margin-bottom: 0; + } + `, + warning: css` + margin-bottom: ${spacing.formInputMargin}; + `, +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.test.tsx b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.test.tsx new file mode 100644 index 0000000000000..346aac1e85162 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { EditAlertRuleTemplateModal } from './EditAlertRuleTemplateModal'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +jest.mock('../AlertRuleTemplate.service'); +jest.mock('app/core/app_events'); + +describe('EditAlertRuleTemplateModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render component correctly', () => { + const { container } = render( + + ); + const addButton = screen.getByTestId('alert-rule-template-edit-button'); + + expect(container.querySelector('textarea')).toBeTruthy(); + expect(addButton).toBeInTheDocument(); + expect(addButton).toBeDisabled(); + expect(screen.getByTestId('alert-rule-template-cancel-button')).toBeInTheDocument(); + expect(screen.getByTestId('alert-rule-name-warning')).toBeInTheDocument(); + }); + + it('should not render modal when visible is set to false', () => { + const { container } = render( + + ); + + expect(container.querySelector('textarea')).not.toBeInTheDocument(); + }); + + it('should call setVisible on close', () => { + const setVisible = jest.fn(); + render( + + ); + + const background = screen.getByTestId('modal-background'); + fireEvent.click(background); + + expect(setVisible).toHaveBeenCalled(); + }); + + it('should render yaml content passed', () => { + const { container } = render( + + ); + expect(container.querySelector('textarea')).toHaveTextContent('test content'); + expect(screen.getByTestId('alert-rule-template-edit-button')).toBeDisabled(); + }); + + it('should call setVisible and getAlertRuleTemplates on submit', async () => { + const setVisible = jest.fn(); + const getAlertRuleTemplates = jest.fn(); + render( + + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'test content' } }); + + const form = screen.getByTestId('edit-alert-rule-template-form'); + await waitFor(() => fireEvent.submit(form)); + + expect(setVisible).toHaveBeenCalledWith(false); + expect(getAlertRuleTemplates).toHaveBeenCalled(); + }); +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.tsx b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.tsx new file mode 100644 index 0000000000000..b030f3d5df197 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.tsx @@ -0,0 +1,76 @@ +import React, { FC } from 'react'; +import { Form } from 'react-final-form'; +import { Button, HorizontalGroup, useStyles } from '@grafana/ui'; +import { AppEvents } from '@grafana/data'; +import { Modal, LoaderButton, TextareaInputField, validators, logger } from '@percona/platform-core'; +import { appEvents } from 'app/core/app_events'; +import { EditAlertRuleTemplateModalProps, EditAlertRuleTemplateRenderProps } from './EditAlertRuleTemplateModal.types'; +import { getStyles } from './EditAlertRuleTemplateModal.styles'; +import { AlertRuleTemplateService } from '../AlertRuleTemplate.service'; +import { Messages } from './EditAlertRuleTemplateModal.messages'; +import { MAX_TITLE_LENGTH } from './EditAlertRuleTemplateModal.constants'; +import { WarningBlock } from 'app/percona/shared/components/Elements/WarningBlock'; + +export const EditAlertRuleTemplateModal: FC = ({ + yaml, + name, + summary, + isVisible, + setVisible, + getAlertRuleTemplates, +}) => { + const styles = useStyles(getStyles); + const { required } = validators; + let truncatedTitle = summary.length > MAX_TITLE_LENGTH ? `${summary.substring(0, MAX_TITLE_LENGTH - 3)}...` : summary; + const onSubmit = async (values: EditAlertRuleTemplateRenderProps) => { + try { + await AlertRuleTemplateService.update({ ...values, name }); + setVisible(false); + appEvents.emit(AppEvents.alertSuccess, [Messages.editSuccess]); + getAlertRuleTemplates(); + } catch (e) { + logger.error(e); + } + }; + + return ( + setVisible(false)}> +
    ( + + <> + + + + + {Messages.submitButton} + + + + + + )} + /> +
    + ); +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.types.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.types.ts new file mode 100644 index 0000000000000..9473a972367c7 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/EditAlertRuleTemplateModal.types.ts @@ -0,0 +1,12 @@ +export interface EditAlertRuleTemplateModalProps { + yaml: string; + name: string; + summary: string; + isVisible: boolean; + setVisible: (value: boolean) => void; + getAlertRuleTemplates: () => void; +} + +export interface EditAlertRuleTemplateRenderProps { + yaml: string; +} diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/index.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/index.ts new file mode 100644 index 0000000000000..023a4c0b474fa --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/EditAlertRuleTemplateModal/index.ts @@ -0,0 +1 @@ +export * from './EditAlertRuleTemplateModal'; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/__mocks__/AlertRuleTemplate.service.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/__mocks__/AlertRuleTemplate.service.ts new file mode 100644 index 0000000000000..55dd2da0ceabb --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/__mocks__/AlertRuleTemplate.service.ts @@ -0,0 +1,17 @@ +import { TemplatesList } from '../AlertRuleTemplate.types'; +import { templateStubs } from './alertRuleTemplateStubs'; + +export const AlertRuleTemplateService = { + async upload(): Promise { + return Promise.resolve(); + }, + async update(): Promise { + return Promise.resolve(); + }, + async list(): Promise { + return { templates: templateStubs, totals: { total_pages: 1, total_items: templateStubs.length } }; + }, + async delete(): Promise { + return Promise.resolve(); + }, +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/__mocks__/alertRuleTemplateStubs.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/__mocks__/alertRuleTemplateStubs.ts new file mode 100644 index 0000000000000..dd7dcb9c012c0 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/__mocks__/alertRuleTemplateStubs.ts @@ -0,0 +1,134 @@ +import { + FormattedTemplate, + SourceDescription, + Template, + TemplateParamType, + TemplateParamUnit, +} from '../AlertRuleTemplate.types'; +import { formatTemplates } from '../AlertRuleTemplate.utils'; + +export const templateStubs: Template[] = [ + { + name: 'template_1', + created_at: '2020-11-25T16:53:39.366Z', + source: SourceDescription.BUILT_IN, + summary: 'MySQL database down', + yaml: 'yaml file content', + params: [ + { + name: 'template-1-threshold', + type: TemplateParamType.FLOAT, + unit: TemplateParamUnit.PERCENTAGE, + summary: 'a threshold', + float: { + hasDefault: true, + hasMin: false, + hasMax: false, + default: 12, + }, + }, + ], + expr: 'template_1_expression', + severity: 'SEVERITY_CRITICAL', + for: '10s', + annotations: { + summary: 'template_1_alert_sample', + }, + }, + { + name: 'template_2', + created_at: '2020-11-25T16:53:39.366Z', + source: SourceDescription.SAAS, + summary: 'MongoDB database down', + yaml: 'yaml file content', + params: [], + expr: '', + severity: 'SEVERITY_NOTICE', + for: '300s', + }, + { + name: 'template_3', + created_at: '2020-11-25T16:53:39.366Z', + source: SourceDescription.USER_FILE, + summary: 'High memory consumption', + yaml: 'yaml file content', + params: [], + expr: '', + severity: 'SEVERITY_WARNING', + for: '15s', + }, + { + name: 'template_4', + created_at: '2020-11-25T16:53:39.366Z', + source: SourceDescription.USER_FILE, + summary: 'Template', + yaml: 'yaml file content', + severity: 'SEVERITY_NOTICE', + for: '20s', + params: [ + { + name: 'template-4-from', + type: TemplateParamType.FLOAT, + unit: TemplateParamUnit.PERCENTAGE, + summary: 'a minimum threshold', + float: { + hasDefault: true, + hasMin: false, + hasMax: false, + default: 10, + }, + }, + { + name: 'template-4-to', + type: TemplateParamType.FLOAT, + unit: TemplateParamUnit.PERCENTAGE, + summary: 'a maximum threshold', + float: { + hasDefault: true, + hasMin: false, + hasMax: false, + default: 50, + }, + }, + ], + expr: '', + }, + { + name: 'template_5', + created_at: '2020-12-25T16:53:39.366Z', + source: SourceDescription.SAAS, + summary: 'Template', + yaml: 'yaml file content', + severity: 'SEVERITY_ERROR', + for: '20s', + params: [ + { + name: 'template-5-from', + type: TemplateParamType.FLOAT, + unit: TemplateParamUnit.PERCENTAGE, + summary: 'a minimum threshold', + float: { + hasDefault: true, + hasMin: false, + hasMax: false, + default: 10, + }, + }, + { + name: 'template-5-to', + type: TemplateParamType.FLOAT, + unit: TemplateParamUnit.PERCENTAGE, + summary: 'a maximum threshold', + float: { + hasDefault: true, + hasMin: false, + hasMax: false, + default: 50, + }, + }, + ], + expr: '', + }, +]; + +export const formattedTemplateStubs: FormattedTemplate[] = formatTemplates(templateStubs); diff --git a/public/app/percona/integrated-alerting/components/AlertRuleTemplate/index.ts b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/index.ts new file mode 100644 index 0000000000000..ca412bcadad2b --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRuleTemplate/index.ts @@ -0,0 +1 @@ +export * from './AlertRuleTemplate'; diff --git a/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.messages.ts b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.messages.ts new file mode 100644 index 0000000000000..dbd8e01af8e81 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.messages.ts @@ -0,0 +1,40 @@ +export const Messages = { + channels: { + email: 'Email', + pagerDuty: 'PagerDuty', + slack: 'Slack', + }, + tooltips: { + template: 'The alert template to use for this rule.', + name: 'The name for this rule.', + duration: 'The alert query duration, in seconds.', + severity: 'The severity level for the alert triggered by this rule.', + channels: 'Which notification channels should be used to send the alert through.', + filters: 'Apply rule only to required services or nodes.', + }, + filter: { + header: 'Filters', + addButton: 'Add Filter', + fieldLabel: 'Label', + fieldOperators: 'Operators', + fieldValue: 'Value', + }, + title: 'Add Alert Rule', + addRuleTitle: 'Add Alert Rule', + editRuleTitle: 'Edit Alert Rule', + create: 'Add', + update: 'Save', + cancel: 'Cancel', + createSuccess: 'Alert rule created', + updateSuccess: 'Alert rule updated', + templateField: 'Template', + nameField: 'Name', + thresholdField: 'Threshold', + durationField: 'Duration (s)', + severityField: 'Severity', + channelField: 'Channels', + activateSwitch: 'Activate', + templateExpression: 'Template Expression', + ruleAlert: 'Rule Alert', + advanced: 'Advanced details', +}; diff --git a/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.styles.ts b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.styles.ts new file mode 100644 index 0000000000000..ff0b1fa5f060a --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.styles.ts @@ -0,0 +1,44 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { spacing } }: GrafanaTheme2) => ({ + actionsWrapper: css` + margin-top: 60px; + `, + form: css` + width: 100%; + `, + toogleField: css` + margin-top: ${spacing.formInputMargin}; + `, + filterRowWrapper: css` + display: flex; + gap: 10px; + margin-bottom: ${spacing.sm}; + `, + filterFields: css` + flex: 1; + `, + filterButton: css` + margin-bottom: ${spacing.md}; + `, + selectField: css` + padding-top: 7px; + padding-bottom: 7px; + `, + iconWrapper: css` + display: flex; + justify-content: center; + align-items: center; + `, + icon: css` + cursor: pointer; + margin-top: 10px; + `, + filtersLabelWrapper: css` + display: flex; + gap: ${spacing.xs}; + margin-bottom: ${spacing.xs}; + align-items: baseline; + `, +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.test.tsx b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.test.tsx new file mode 100644 index 0000000000000..62bafec601c72 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.test.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import { AddAlertRuleModal } from './AddAlertRuleModal'; +import { AlertRule } from '../AlertRules.types'; +import { templateStubs } from '../../AlertRuleTemplate/__mocks__/alertRuleTemplateStubs'; +import { SEVERITY_OPTIONS } from './AddAlertRulesModal.constants'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +jest.mock('../AlertRules.service'); +jest.mock('../../AlertRuleTemplate/AlertRuleTemplate.service'); +jest.mock('../../NotificationChannel/NotificationChannel.service'); + +jest.mock('app/core/app_events', () => { + return { + appEvents: { + emit: jest.fn(), + }, + }; +}); + +describe('AddAlertRuleModal', () => { + const { name: templateName, summary: templateSummary, params: templateParams = [] } = templateStubs[0]; + const initialValues: AlertRule = { + ruleId: '/rule_id/ded33d30-1b65-4b43-ba45-75ca52b48fa5', + createdAt: '2021-01-19 12:53:16.082', + duration: '3600s', + filters: [], + severity: 'Critical', + name: 'Alert1', + lastNotified: '', + disabled: false, + expr: '', + params: templateParams.map(({ name, type, unit, summary }) => ({ name, type, summary, unit, value: 10 })), + rawValues: { + channels: [], + filters: [], + disabled: false, + expr: '', + rule_id: '/rule_id/ded33d30-1b65-4b43-ba45-75ca52b48fa5', + summary: templateSummary, + params_definitions: [], + params_values: [], + default_for: '3600s', + for: '3600s', + default_severity: 'SEVERITY_CRITICAL', + name: templateName, + expr_template: '', + template_name: templateName, + severity: 'SEVERITY_CRITICAL', + created_at: '2021-01-19T12:53:16.082610Z', + }, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render modal', async () => { + await waitFor(() => render()); + + expect(screen.getByTestId('modal-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('add-alert-rule-modal-form')).toBeInTheDocument(); + expect(screen.getByTestId('add-alert-rule-modal-add-button')).toBeInTheDocument(); + }); + + it('does not render the modal when visible is set to false', async () => { + await waitFor(() => render()); + + expect(screen.queryByTestId('add-alert-rule-modal-form')).not.toBeInTheDocument(); + }); + + it('renders the modal when visible is set to true', async () => { + await waitFor(() => render()); + + expect(screen.getByTestId('add-alert-rule-modal-form')).toBeInTheDocument(); + }); + + it('should have the submit button disabled by default when adding a new rule', async () => { + await waitFor(() => render()); + + expect(screen.getByTestId('add-alert-rule-modal-add-button')).toBeDisabled(); + }); + + it('should enable the submit button if all fields are valid', async () => { + await waitFor(() => render()); + + const thresholdInput = screen.getByTestId(`${templateParams[0].name}-number-input`); + await waitFor(() => + fireEvent.change(thresholdInput, { + target: { + value: '2', + }, + }) + ); + + expect(screen.getByTestId('add-alert-rule-modal-add-button')).toHaveProperty('disabled', false); + }); + + it('should disable the submit button if a negative duration is inserted', async () => { + await waitFor(() => render()); + + const thresholdInput = screen.getByTestId(`${templateParams[0].name}-number-input`); + const durationInput = screen.getByTestId('duration-number-input'); + + await waitFor(() => + fireEvent.change(thresholdInput, { + target: { + value: '2', + }, + }) + ); + + await waitFor(() => + fireEvent.change(durationInput, { + target: { + value: '-10', + }, + }) + ); + + expect(screen.getByTestId('add-alert-rule-modal-add-button')).toBeDisabled(); + }); + + it('should disable template edition', async () => { + await waitFor(() => render()); + // checking template-select-input + expect(screen.getByTestId('add-alert-rule-modal-form').querySelectorAll('input')[0]).toHaveProperty( + 'disabled', + true + ); + }); + + it('should change params when switching templates', async () => { + const { container } = await waitFor(() => render()); + + expect(screen.queryByTestId('template-1-threshold-number-input')).not.toBeInTheDocument(); + + await waitFor(() => fireEvent.keyDown(container.querySelectorAll('input')[0], { key: 'ArrowDown' })); + await waitFor(() => fireEvent.click(screen.getAllByLabelText('Select option')[0])); + + expect(screen.getByTestId('template-1-threshold-number-input')).toBeInTheDocument(); + + await waitFor(() => fireEvent.keyDown(container.querySelectorAll('input')[0], { key: 'ArrowDown' })); + await waitFor(() => fireEvent.click(screen.getAllByLabelText('Select option')[3])); + + expect(screen.queryByTestId('template-1-threshold-number-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('template-4-from-number-input')).toBeInTheDocument(); + expect(screen.getByTestId('template-4-to-number-input')).toBeInTheDocument(); + }); + + it('should pre-fill severity and duration when switching templates', async () => { + const { container } = await waitFor(() => render()); + + expect(screen.getByTestId('duration-number-input').textContent).toHaveLength(0); + + // checking severity-select-input + const selectContainer = screen.getByText('Severity')?.parentElement?.nextSibling; + expect(selectContainer).toHaveTextContent('Choose'); + + await waitFor(() => fireEvent.keyDown(container.querySelectorAll('input')[0], { key: 'ArrowDown' })); + await waitFor(() => fireEvent.click(screen.getAllByLabelText('Select option')[0])); + + expect(screen.getByTestId('duration-number-input')).toHaveValue(parseInt(templateStubs[0].for, 10)); + expect(screen.getByText('Severity')?.parentElement?.nextSibling).toHaveTextContent( + `${SEVERITY_OPTIONS.find((severity) => severity.value === templateStubs[0].severity)?.label}` + ); + }); + + xit('should show the expression and sample alert when switching templates', async () => { + const { container } = await waitFor(() => render()); + + expect(screen.queryByTestId('template-expression')).not.toBeInTheDocument(); + expect(screen.queryByTestId('template-alert')).not.toBeInTheDocument(); + + await waitFor(() => fireEvent.keyDown(container.querySelectorAll('input')[0], { key: 'ArrowDown' })); + await waitFor(() => fireEvent.click(screen.getAllByLabelText('Select option')[0])); + + expect(screen.getByTestId('template-expression').querySelector('pre')).toHaveTextContent(templateStubs[0].expr); + expect(screen.getByTestId('template-alert').querySelector('pre')?.textContent).toEqual( + templateStubs[0].annotations?.summary + ); + }); + + it('should add filter fields when clicked on add filter button', async () => { + render(); + fireEvent.click(screen.getByTestId('add-filter-button')); + expect(screen.getByTestId('filter-fields-row')).toBeInTheDocument(); + }); + + it('should remove filter field when clicked on remove filter icon', async () => { + render(); + fireEvent.click(screen.getByTestId('add-filter-button')); + expect(screen.getByTestId('filter-fields-row')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('delete-filter-button')); + expect(screen.queryByTestId('filter-fields-row')).not.toBeInTheDocument(); + }); + + it('should disable submit button when new filter field without data is added', async () => { + await waitFor(() => render()); + + const thresholdInput = screen.getByTestId(`${templateParams[0].name}-number-input`); + await waitFor(() => + fireEvent.change(thresholdInput, { + target: { + value: '2', + }, + }) + ); + + fireEvent.click(screen.getByTestId('add-filter-button')); + expect(screen.getByTestId('filter-fields-row')).toBeInTheDocument(); + + expect(screen.getByTestId('add-alert-rule-modal-add-button')).toBeDisabled(); + }); + + it('should enable submit button again when filter field without data is deleted', async () => { + await waitFor(() => render()); + + const thresholdInput = screen.getByTestId(`${templateParams[0].name}-number-input`); + await waitFor(() => + fireEvent.change(thresholdInput, { + target: { + value: '2', + }, + }) + ); + + fireEvent.click(screen.getByTestId('add-filter-button')); + expect(screen.getByTestId('filter-fields-row')).toBeInTheDocument(); + + expect(screen.getByTestId('add-alert-rule-modal-add-button')).toBeDisabled(); + + fireEvent.click(screen.getByTestId('delete-filter-button')); + expect(screen.queryByTestId('filter-fields-row')).not.toBeInTheDocument(); + + expect(screen.getByTestId('add-alert-rule-modal-add-button')).not.toBeDisabled(); + }); +}); diff --git a/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.tsx b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.tsx new file mode 100644 index 0000000000000..2d17d9c650ab0 --- /dev/null +++ b/public/app/percona/integrated-alerting/components/AlertRules/AddAlertRuleModal/AddAlertRuleModal.tsx @@ -0,0 +1,338 @@ +import React, { FC, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { Form, Field } from 'react-final-form'; +import { FieldArray } from 'react-final-form-arrays'; +import arrayMutators from 'final-form-arrays'; +import { Button, HorizontalGroup, Icon, Switch, useStyles2 } from '@grafana/ui'; +import { Modal, LoaderButton, TextInputField, NumberInputField, logger, validators } from '@percona/platform-core'; +import { AppEvents, SelectableValue } from '@grafana/data'; +import { Label } from 'app/percona/shared/components/Form/Label'; +import { SelectField } from 'app/percona/shared/components/Form/SelectField'; +import { MultiSelectField } from 'app/percona/shared/components/Form/MultiSelectField'; +import { Messages } from './AddAlertRuleModal.messages'; +import { AddAlertRuleModalProps, AddAlertRuleFormValues } from './AddAlertRuleModal.types'; +import { getStyles } from './AddAlertRuleModal.styles'; +import { SEVERITY_OPTIONS, MINIMUM_DURATION_VALUE } from './AddAlertRulesModal.constants'; +import { LinkTooltip } from 'app/percona/shared/components/Elements/LinkTooltip/LinkTooltip'; +import { + formatTemplateOptions, + formatChannelsOptions, + formatCreateAPIPayload, + formatUpdateAPIPayload, + getInitialValues, + minValidator, +} from './AddAlertRuleModal.utils'; +import { AlertRulesProvider } from '../AlertRules.provider'; +import { AlertRulesService } from '../AlertRules.service'; +import { AlertRuleTemplateService } from '../../AlertRuleTemplate/AlertRuleTemplate.service'; +import { Template, TemplateParamType } from '../../AlertRuleTemplate/AlertRuleTemplate.types'; +import { NotificationChannelService } from '../../NotificationChannel/NotificationChannel.service'; +import { appEvents } from 'app/core/core'; +import { AdvancedRuleSection } from './AdvancedRuleSection/AdvancedRuleSection'; +import { AlertRuleParamField } from '../AlertRuleParamField'; +import { AlertRuleFilterType } from '../AlertRules.types'; + +const { required } = validators; +const durationValidators = [required, minValidator(MINIMUM_DURATION_VALUE)]; +const nameValidators = [required]; +const filterTextFieldsValidators = [required]; + +export const AddAlertRuleModal: FC = ({ isVisible, setVisible, alertRule }) => { + const styles = useStyles2(getStyles); + const [templateOptions, setTemplateOptions] = useState>>(); + const [channelsOptions, setChannelsOptions] = useState>>(); + + const filterOptions: Array> = useMemo( + () => + Object.entries(AlertRuleFilterType).map(([key, value]) => ({ + label: `${value} (${key})`, + value: value, + })), + [] + ); + const templates = useRef([]); + const [currentTemplate, setCurrentTemplate] = useState