diff --git a/.betterer.results b/.betterer.results index f540cfeba20..235ffb75cae 100644 --- a/.betterer.results +++ b/.betterer.results @@ -8,21 +8,9 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx:2315765050": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/BigValue/BigValue.test.tsx:261212211": [ - [1, 19, 13, "RegExp match", "2409514259"] - ], - "packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx:2771878691": [ - [1, 18, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx:1355456933": [ [1, 31, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx:3194817479": [ - [2, 33, 13, "RegExp match", "2409514259"] - ], - "packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx:3838344574": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/FileUpload/FileUpload.test.tsx:3475964456": [ [1, 19, 13, "RegExp match", "2409514259"] ], @@ -116,7 +104,7 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js:2288177454": [ [15, 19, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2545268168": [ + "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2127169675": [ [15, 17, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js:2454947085": [ @@ -197,9 +185,6 @@ exports[`no enzyme tests`] = { "public/app/core/components/Select/MetricSelect.test.tsx:3409251428": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/core/specs/OrgSwitcher.test.tsx:848670248": [ - [2, 19, 13, "RegExp match", "2409514259"] - ], "public/app/features/alerting/AlertRuleList.test.tsx:1800339390": [ [1, 19, 13, "RegExp match", "2409514259"] ], @@ -218,7 +203,7 @@ exports[`no enzyme tests`] = { "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:4134073823": [ [1, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1141305288": [ + "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [ [1, 35, 13, "RegExp match", "2409514259"] ], "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:1798654441": [ @@ -290,6 +275,9 @@ exports[`no enzyme tests`] = { "public/app/features/folders/FolderSettingsPage.test.tsx:1751147194": [ [2, 19, 13, "RegExp match", "2409514259"] ], + "public/app/features/invites/InviteesTable.test.tsx:3077684439": [ + [1, 19, 13, "RegExp match", "2409514259"] + ], "public/app/features/org/OrgDetailsPage.test.tsx:2540662821": [ [1, 19, 13, "RegExp match", "2409514259"] ], @@ -317,13 +305,10 @@ exports[`no enzyme tests`] = { "public/app/features/teams/TeamSettings.test.tsx:2628968507": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/users/InviteesTable.test.tsx:2271264692": [ - [1, 19, 13, "RegExp match", "2409514259"] - ], "public/app/features/users/UsersActionBar.test.tsx:4031641375": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/users/UsersListPage.test.tsx:2523261097": [ + "public/app/features/users/UsersListPage.test.tsx:2626906707": [ [1, 19, 13, "RegExp match", "2409514259"] ], "public/app/features/users/UsersTable.test.tsx:3051231816": [ diff --git a/.drone.star b/.drone.star index f025f8df415..d2723f88be8 100644 --- a/.drone.star +++ b/.drone.star @@ -6,6 +6,7 @@ load('scripts/drone/pipelines/pr.star', 'pr_pipelines') load('scripts/drone/pipelines/main.star', 'main_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') @@ -13,7 +14,7 @@ load('scripts/drone/vault.star', 'secrets') def main(ctx): edition = 'oss' - return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ + return pr_pipelines(edition=edition) + docs_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ publish_image_pipelines('public') + publish_image_pipelines('security') + \ publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \ publish_npm_pipelines('public') + publish_packages_pipeline() + \ diff --git a/.drone.yml b/.drone.yml index 21d0852c426..a3ec9b7f565 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -93,6 +93,9 @@ steps: trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker volumes: - host: @@ -111,7 +114,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -288,20 +291,6 @@ steps: failure: always image: grafana/docker-puppeteer:1.0.0 name: test-a11y-frontend -- commands: - - ./scripts/ci-reference-docs-lint.sh ci - depends_on: - - build-frontend - image: grafana/build-container:1.4.9 - 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/ @@ -324,6 +313,9 @@ steps: trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker volumes: - host: @@ -362,7 +354,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -399,6 +391,9 @@ steps: trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker volumes: - host: @@ -413,6 +408,68 @@ volumes: --- depends_on: [] kind: pipeline +name: pr-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.3/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.4.9 + name: initialize +- commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id + ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.4.9 + name: build-frontend +- commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend + image: grafana/build-container:1.4.9 + 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-test node: type: no-parallel @@ -423,7 +480,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -524,7 +581,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -909,7 +966,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -977,7 +1034,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1060,7 +1117,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1142,7 +1199,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1446,7 +1503,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1567,7 +1624,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1651,7 +1708,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1710,7 +1767,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2050,7 +2107,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2223,7 +2280,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2351,7 +2408,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2426,7 +2483,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2504,7 +2561,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2565,7 +2622,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2644,7 +2701,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2706,7 +2763,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2742,7 +2799,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2789,7 +2846,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2837,7 +2894,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2900,7 +2957,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3159,7 +3216,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3273,7 +3330,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3350,7 +3407,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3398,7 +3455,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3731,7 +3788,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3894,7 +3951,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4012,7 +4069,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4209,6 +4266,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 559c02d03809e345c15ec6badc083947ac12d79dc851596a317a5bd83ad75941 +hmac: 703b9a44a073ab768a774029cee2c3fc7c5fe7b7e01495e18f048bc7d1f007b0 ... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 88f575d5b9d..495abdd9d94 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -104,7 +104,7 @@ go.sum @grafana/backend-platform /public/app/features/live/ @grafana/grafana-edge-squad /public/app/features/explore/ @grafana/observability-experience-squad /public/app/features/plugins @grafana/plugins-platform-frontend -/public/app/core/components/TransformersUI/spatial @grafana/grafana-edge-squad +/public/app/features/transformers/spatial @grafana/grafana-edge-squad /public/app/plugins/panel/alertlist @grafana/alerting-squad /public/app/plugins/panel/barchart @grafana/grafana-bi-squad /public/app/plugins/panel/heatmap @grafana/grafana-bi-squad diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml index 41d3a05aa39..87096eaf159 100644 --- a/.github/workflows/detect-breaking-changes-build.yml +++ b/.github/workflows/detect-breaking-changes-build.yml @@ -112,7 +112,7 @@ jobs: - name: Get link for the Github Action job id: job - uses: actions/github-script@v5 + uses: actions/github-script@v6 with: script: | const script = require('./.github/workflows/scripts/pr-get-job-link.js') diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml index f7a096a288e..fe5f2f1f490 100644 --- a/.github/workflows/detect-breaking-changes-report.yml +++ b/.github/workflows/detect-breaking-changes-report.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - name: 'Download artifact' - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: RUN_ID: ${{ github.event.workflow_run.id }} with: @@ -53,7 +53,7 @@ jobs: run: unzip "${ARTIFACT_FOLDER}/${ARTIFACT_NAME}.zip" -d "${ARTIFACT_FOLDER}" - name: Parsing levitate result - uses: actions/github-script@v5 + uses: actions/github-script@v6 id: levitate-run with: script: | @@ -63,7 +63,7 @@ jobs: - name: Check if "breaking change" label exists id: does-label-exist - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} with: @@ -91,7 +91,7 @@ jobs: ${{ steps.levitate-run.outputs.message }} [Console output](${{ steps.levitate-run.outputs.job_link }}) - [Read our guideline](../../contribute/breaking-changes-guide.md) + [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide.md) - name: Remove comment on PR if: ${{ steps.levitate-run.outputs.exit_code == 0 }} @@ -119,7 +119,7 @@ jobs: - name: Add "breaking change" label if: ${{ steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: @@ -134,7 +134,7 @@ jobs: - name: Remove "breaking change" label if: ${{ steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: @@ -151,7 +151,7 @@ jobs: # Related issue: https://github.com/renovatebot/renovate/issues/1908 - name: Add "grafana/plugins-platform-frontend" as a reviewer if: ${{ steps.levitate-run.outputs.exit_code == 1 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: @@ -167,7 +167,7 @@ jobs: - name: Remove "grafana/plugins-platform-frontend" from the list of reviewers if: ${{ steps.levitate-run.outputs.exit_code == 0 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: diff --git a/.vscode/launch.json b/.vscode/launch.json index 5eaae3ff47b..112f73498bc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,8 @@ "name": "Debug Jest test", "type": "node", "request": "launch", - "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand", "${file}"], + "runtimeExecutable": "yarn", + "runtimeArgs": ["run", "jest", "--runInBand", "${file}"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "port": 9229 diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d57c1e256..30bd5c155a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ + + +# 8.4.1 (2022-02-18) + +### Features and enhancements + +- **Cloudwatch:** Add support for AWS/PrivateLink\* metrics and dimensions. [#45515](https://github.com/grafana/grafana/pull/45515), [@szymonpk](https://github.com/szymonpk) +- **Configuration:** Add ability to customize okta login button name and icon. [#44079](https://github.com/grafana/grafana/pull/44079), [@DanCech](https://github.com/DanCech) +- **Tempo:** Switch out Select with AsyncSelect component to get loading state in Tempo Search. [#45110](https://github.com/grafana/grafana/pull/45110), [@CatPerry](https://github.com/CatPerry) + +### Bug fixes + +- **Alerting:** Fix migrations by making send_alerts_to field nullable. [#45572](https://github.com/grafana/grafana/pull/45572), [@santihernandezc](https://github.com/santihernandezc) + + + + +# 8.4.0 (2022-02-16) + +### Features and enhancements + +- **API:** Extract OpenAPI specification from source code using go-swagger. [#40528](https://github.com/grafana/grafana/pull/40528), [@papagian](https://github.com/papagian) +- **AccessControl:** Disable user remove and user update roles when they do not have the permissions. [#43429](https://github.com/grafana/grafana/pull/43429), [@Jguer](https://github.com/Jguer) +- **AccessControl:** Provisioning for teams. [#43767](https://github.com/grafana/grafana/pull/43767), [@gamab](https://github.com/gamab) +- **API:** Add usage stats preview endpoint. [#43899](https://github.com/grafana/grafana/pull/43899), [@Jguer](https://github.com/Jguer) +- **Alerting:** Move slow queries in the scheduler to another goroutine. [#44423](https://github.com/grafana/grafana/pull/44423), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Use time.Ticker instead of alerting.Ticker in ngalert. [#44395](https://github.com/grafana/grafana/pull/44395), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** add custom grouping to Alert Panel. [#44559](https://github.com/grafana/grafana/pull/44559), [@gillesdemey](https://github.com/gillesdemey) +- **Analytics:** Add user id tracking to google analytics. [#42763](https://github.com/grafana/grafana/pull/42763), [@autoric](https://github.com/autoric) +- **Angular:** Add AngularJS plugin support deprecation plan to docs site. [#45149](https://github.com/grafana/grafana/pull/45149), [@torkelo](https://github.com/torkelo) +- **Auth:** implement auto_sign_up for auth.jwt. [#43502](https://github.com/grafana/grafana/pull/43502), [@sakjur](https://github.com/sakjur) +- **Azure Monitor Logs:** Order subscriptions in resource picker by name. [#45228](https://github.com/grafana/grafana/pull/45228), [@sunker](https://github.com/sunker) +- **Azure monitor Logs:** Optimize data fetching in resource picker. [#44549](https://github.com/grafana/grafana/pull/44549), [@sunker](https://github.com/sunker) +- **AzureMonitor:** Filter list of resources by resourceType. [#43522](https://github.com/grafana/grafana/pull/43522), [@andresmgot](https://github.com/andresmgot) +- **BarChart:** color by field, x time field, bar radius, label skipping. [#43257](https://github.com/grafana/grafana/pull/43257), [@leeoniya](https://github.com/leeoniya) +- **Chore:** Implement OpenTelemetry in Grafana. [#42674](https://github.com/grafana/grafana/pull/42674), [@idafurjes](https://github.com/idafurjes) +- **Cloud Monitoring:** Adds metric type to Metric drop down options. [#43268](https://github.com/grafana/grafana/pull/43268), [@tw1nk](https://github.com/tw1nk) +- **CloudWatch:** Add Data Lifecycle Manager metrics and dimension. [#43310](https://github.com/grafana/grafana/pull/43310), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch:** Add Missing Elasticache Host-level metrics. [#43455](https://github.com/grafana/grafana/pull/43455), [@dhendo](https://github.com/dhendo) +- **CloudWatch:** Add all ElastiCache Redis Metrics. [#43336](https://github.com/grafana/grafana/pull/43336), [@siavashs](https://github.com/siavashs) +- **CloudWatch:** Add new AWS/ES metrics. [#43034](https://github.com/grafana/grafana/pull/43034), [@sunker](https://github.com/sunker) +- **Cloudwatch:** Add syntax highlighting and autocomplete for "Metric Search". [#43985](https://github.com/grafana/grafana/pull/43985), [@sarahzinger](https://github.com/sarahzinger) +- **Explore:** Support custom display label for exemplar links for Prometheus datasource. [#42732](https://github.com/grafana/grafana/pull/42732), [@JokerQueue](https://github.com/JokerQueue) +- **Hotkeys:** Make time range absolute/permanent. [#43802](https://github.com/grafana/grafana/pull/43802), [@davkal](https://github.com/davkal) +- **Playlists:** Enable sharing direct links to playlists. [#44161](https://github.com/grafana/grafana/pull/44161), [@ashharrison90](https://github.com/ashharrison90) +- **SQLStore:** Prevent concurrent migrations. [#44101](https://github.com/grafana/grafana/pull/44101), [@papagian](https://github.com/papagian) +- **SSE:** Add Mode to drop NaN/Inf/Null in Reduction operations. [#43583](https://github.com/grafana/grafana/pull/43583), [@kylebrandt](https://github.com/kylebrandt) +- **Setting:** Support configuring feature toggles with bools instead of just passing an array. [#43326](https://github.com/grafana/grafana/pull/43326), [@bergquist](https://github.com/bergquist) +- **TimeSeries:** Add support for negative Y and constant transform. [#44774](https://github.com/grafana/grafana/pull/44774), [@dprokop](https://github.com/dprokop) +- **Transformations:** Add 'JSON' field type to ConvertFieldTypeTransformer. [#42624](https://github.com/grafana/grafana/pull/42624), [@sd2k](https://github.com/sd2k) + +### Bug fixes + +- **Auth:** Guarantee consistency of signed SigV4 headers. [#45054](https://github.com/grafana/grafana/pull/45054), [@wbrowne](https://github.com/wbrowne) +- **CloudWatch:** Fix MetricName resetting on Namespace change. [#44165](https://github.com/grafana/grafana/pull/44165), [@yaelleC](https://github.com/yaelleC) +- **Cloudwatch :** Fixed resetting metric name when changing namespace in Metric Query. [#44612](https://github.com/grafana/grafana/pull/44612), [@yaelleC](https://github.com/yaelleC) +- **Explore:** Avoid locking timepicker when range is inverted. [#44790](https://github.com/grafana/grafana/pull/44790), [@Elfo404](https://github.com/Elfo404) +- **Instrumentation:** Fix HTTP request instrumentation of authentication failures. [#44234](https://github.com/grafana/grafana/pull/44234), [@marefr](https://github.com/marefr) +- **LibraryPanels:** Prevent long descriptions and names from obscuring the delete button. [#45190](https://github.com/grafana/grafana/pull/45190), [@zuchka](https://github.com/zuchka) +- **OAuth:** Fix parsing of ID token if header contains non-string value. [#44159](https://github.com/grafana/grafana/pull/44159), [@marefr](https://github.com/marefr) +- **Panel Edit:** Visualization search now works correctly with special characters. [#45137](https://github.com/grafana/grafana/pull/45137), [@ashharrison90](https://github.com/ashharrison90) +- **Provisioning:** Fix duplicate validation when multiple organizations have been configured. [#44151](https://github.com/grafana/grafana/pull/44151), [@marefr](https://github.com/marefr) +- **QueryField:** Fix issue with undo history when suggestion is inserted (#28656). [#39114](https://github.com/grafana/grafana/pull/39114), [@glintik](https://github.com/glintik) +- **TablePanel:** Do not prefix columns with frame name if multiple frames and override active. [#45174](https://github.com/grafana/grafana/pull/45174), [@mdvictor](https://github.com/mdvictor) + +### Deprecations + +AngularJS plugin support is now in a deprecated state, meaning it will be removed in a future release. Currently, that is planned for version 10 (in 2023). The documentation site has an [article](https://grafana.com/docs/grafana/next/developers/angular_deprecation/) with more details on why, when, and how. Issue [#45149](https://github.com/grafana/grafana/issues/45149) + + # 8.4.0-beta1 (2022-02-02) diff --git a/conf/defaults.ini b/conf/defaults.ini index a852dee4e75..738b33889ac 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -292,6 +292,9 @@ content_security_policy = false # $ROOT_PATH is server.root_url without the protocol. content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" +# Controls if old angular plugins are supported or not. This will be disabled by default in Grafana v9. +angular_support_enabled = true + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -497,6 +500,7 @@ allowed_groups = #################################### Okta OAuth ####################### [auth.okta] name = Okta +icon = okta enabled = false allow_sign_up = true client_id = some_id @@ -513,6 +517,7 @@ role_attribute_strict = false #################################### Generic OAuth ####################### [auth.generic_oauth] name = OAuth +icon = signin enabled = false allow_sign_up = true client_id = some_id diff --git a/conf/sample.ini b/conf/sample.ini index 710a8ea4810..759fd56af35 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -292,6 +292,9 @@ # $ROOT_PATH is server.root_url without the protocol. ;content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" +# Controls if old angular plugins are supported or not. This will be disabled by default in Grafana v9. +;angular_support_enabled = true + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/devenv/docker/blocks/saml-enterprise b/devenv/docker/blocks/saml-enterprise deleted file mode 120000 index c3916961dee..00000000000 --- a/devenv/docker/blocks/saml-enterprise +++ /dev/null @@ -1 +0,0 @@ -/Users/mitchel/Workspace/grafana-enterprise/src/devenv/blocks/saml-enterprise \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 9d6abafad4d..d15a584b9c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Yarn >= 1.22.4 ## Build the doc site 1. On the command line, first change to the docs folder: `cd docs`. -1. Run `make docs-quick`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/grafana/next/` which will refresh automatically when changes are made to content in the `sources` directory. +1. Run `make docs`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/grafana/next/` which will refresh automatically when changes are made to content in the `sources` directory. If you have the grafana/website repo checked out in the same directory as the grafana repo, then you can run `make docs-local-static` to use local assets (such as images). diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index ecdc40b5386..1600c0420b5 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -582,6 +582,21 @@ Set Content Security Policy template used when adding the Content-Security-Polic
+### angular_support_enabled + +This currently defaults to `true` but will in Grafana v9 default to `false`. When set to false the angular framework and support components will not be loaded. This means that +all plugins and core features that depend on angular support will stop working. + +Current core features that will stop working: + +- Heatmap panel +- Old graph panel +- Old table panel +- Postgres, MySQL and MSSQL data source query editors +- Legacy alerting edit rule UI + +Before we disable angular support by default we plan to migrate these remaining areas to React. + ## [snapshots] ### external_enabled diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index 444e92f68c1..053758777fb 100755 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -27,6 +27,8 @@ Example config: ```bash [auth.generic_oauth] +name = OAuth +icon = signin enabled = true client_id = YOUR_APP_CLIENT_ID client_secret = YOUR_APP_CLIENT_SECRET diff --git a/docs/sources/auth/okta.md b/docs/sources/auth/okta.md index c1751e134fb..a1d41babe58 100644 --- a/docs/sources/auth/okta.md +++ b/docs/sources/auth/okta.md @@ -38,6 +38,7 @@ Before you can sign a user in, you need to create an Okta application from the O ```ini [auth.okta] name = Okta +icon = okta enabled = true allow_sign_up = true client_id = some_id diff --git a/docs/sources/enterprise/query-caching.md b/docs/sources/enterprise/query-caching.md index 02d25a57510..b58e5c351a8 100644 --- a/docs/sources/enterprise/query-caching.md +++ b/docs/sources/enterprise/query-caching.md @@ -55,11 +55,11 @@ By default, data source queries are not cached. To enable query caching for a si 1. In the data source list, click the data source that you want to turn on caching for. 1. Open the Cache tab. 1. Press the Enable button. -1. (Optional) Choose a custom TTL for that data source. If you skip this step, then Grafana uses the default TTL. +1. (Optional) Choose custom TTLs for the data source's queries and resources caching. If you skip this step, then Grafana uses the default TTL. > **Note:** If query caching is enabled and the Cache tab is not visible in a data source's settings, then query caching is not available for that data source. -To configure global settings for query caching, refer the the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). +To configure global settings for query caching, refer to the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). ## Disable query caching @@ -71,6 +71,19 @@ To disable query caching for a single data source: To disable query caching for an entire Grafana instance, set the `enabled` flag to `false` in the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). You will no longer see the Cache tab on any data sources, and no data source queries will be cached. +## Clear cache + +If you experience performance issues or repeated queries become slower to execute, consider clearing your cache. + +> **Note:** This action impacts all cache-enabled data sources. If you are using Memcached, the system clears all data from the Memcached instance. + +**To clear the cache**: + +1. Sign in to Grafana and click **Settings > Data Sources**. +1. Select a data source. +1. Click the **Cache** tab. +1. Click **Clear cache**. + ## Sending a request without cache If a data source query request contains an `X-Cache-Skip` header, then Grafana skips the caching middleware, and does not search the cache for a response. This can be particularly useful when debugging data source queries using cURL. diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 50f394afe38..51b33a7d632 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -29,7 +29,9 @@ If the data source does not support loading full range log volume histogram, the #### Log level -For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to parse the log using logfmt and JSON parsers to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level. +For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level. + +> **Tip:** If you use Loki data source and the "level" is in you log content, try to use parsers (JSON, logfmt, regex,..) to extract level information into level label that is used to determine log level. **Supported log levels and mapping of log level abbreviation and expressions:** diff --git a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md index 3563b22e542..9085d2a400e 100644 --- a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md +++ b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md @@ -16,8 +16,6 @@ Expressions are primarily used by the new [Grafana 8 alerts]({{< relref "../../. > **Note:** Expressions do not work with legacy dashboard alerts. -> **Note:** Expressions do not work with dashboard variables. - Expressions are meant to augment data sources by enabling queries from different data sources to be combined or by providing operations unavailable in a data source. > **Note:** When possible, you should do data processing inside the data source. Copying data from storage to the Grafana server for processing is inefficient, so expressions are targeted at lightweight data processing. diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 5b9b46103f7..cb08b39c58e 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,7 @@ 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.4.1]({{< relref "release-notes-8-4-1" >}}) - [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}}) - [Release notes for 8.3.6]({{< relref "release-notes-8-3-6" >}}) - [Release notes for 8.3.5]({{< relref "release-notes-8-3-5" >}}) diff --git a/docs/sources/release-notes/release-notes-8-4-0.md b/docs/sources/release-notes/release-notes-8-4-0.md new file mode 100644 index 00000000000..0576173e6e8 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-0.md @@ -0,0 +1,58 @@ ++++ +title = "Release notes for Grafana 8.4.0" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.0 + +### Features and enhancements + +- **API:** Extract OpenAPI specification from source code using go-swagger. [#40528](https://github.com/grafana/grafana/pull/40528), [@papagian](https://github.com/papagian) +- **AccessControl:** Disable user remove and user update roles when they do not have the permissions. [#43429](https://github.com/grafana/grafana/pull/43429), [@Jguer](https://github.com/Jguer) +- **AccessControl:** Provisioning for teams. [#43767](https://github.com/grafana/grafana/pull/43767), [@gamab](https://github.com/gamab) +- **API:** Add usage stats preview endpoint. [#43899](https://github.com/grafana/grafana/pull/43899), [@Jguer](https://github.com/Jguer) +- **Alerting:** Move slow queries in the scheduler to another goroutine. [#44423](https://github.com/grafana/grafana/pull/44423), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Use time.Ticker instead of alerting.Ticker in ngalert. [#44395](https://github.com/grafana/grafana/pull/44395), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** add custom grouping to Alert Panel. [#44559](https://github.com/grafana/grafana/pull/44559), [@gillesdemey](https://github.com/gillesdemey) +- **Analytics:** Add user id tracking to google analytics. [#42763](https://github.com/grafana/grafana/pull/42763), [@autoric](https://github.com/autoric) +- **Angular:** Add AngularJS plugin support deprecation plan to docs site. [#45149](https://github.com/grafana/grafana/pull/45149), [@torkelo](https://github.com/torkelo) +- **Auth:** implement auto_sign_up for auth.jwt. [#43502](https://github.com/grafana/grafana/pull/43502), [@sakjur](https://github.com/sakjur) +- **Azure Monitor Logs:** Order subscriptions in resource picker by name. [#45228](https://github.com/grafana/grafana/pull/45228), [@sunker](https://github.com/sunker) +- **Azure monitor Logs:** Optimize data fetching in resource picker. [#44549](https://github.com/grafana/grafana/pull/44549), [@sunker](https://github.com/sunker) +- **AzureMonitor:** Filter list of resources by resourceType. [#43522](https://github.com/grafana/grafana/pull/43522), [@andresmgot](https://github.com/andresmgot) +- **BarChart:** color by field, x time field, bar radius, label skipping. [#43257](https://github.com/grafana/grafana/pull/43257), [@leeoniya](https://github.com/leeoniya) +- **Chore:** Implement OpenTelemetry in Grafana. [#42674](https://github.com/grafana/grafana/pull/42674), [@idafurjes](https://github.com/idafurjes) +- **Cloud Monitoring:** Adds metric type to Metric drop down options. [#43268](https://github.com/grafana/grafana/pull/43268), [@tw1nk](https://github.com/tw1nk) +- **CloudWatch:** Add Data Lifecycle Manager metrics and dimension. [#43310](https://github.com/grafana/grafana/pull/43310), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch:** Add Missing Elasticache Host-level metrics. [#43455](https://github.com/grafana/grafana/pull/43455), [@dhendo](https://github.com/dhendo) +- **CloudWatch:** Add all ElastiCache Redis Metrics. [#43336](https://github.com/grafana/grafana/pull/43336), [@siavashs](https://github.com/siavashs) +- **CloudWatch:** Add new AWS/ES metrics. [#43034](https://github.com/grafana/grafana/pull/43034), [@sunker](https://github.com/sunker) +- **Cloudwatch:** Add syntax highlighting and autocomplete for "Metric Search". [#43985](https://github.com/grafana/grafana/pull/43985), [@sarahzinger](https://github.com/sarahzinger) +- **Explore:** Support custom display label for exemplar links for Prometheus datasource. [#42732](https://github.com/grafana/grafana/pull/42732), [@JokerQueue](https://github.com/JokerQueue) +- **Hotkeys:** Make time range absolute/permanent. [#43802](https://github.com/grafana/grafana/pull/43802), [@davkal](https://github.com/davkal) +- **Playlists:** Enable sharing direct links to playlists. [#44161](https://github.com/grafana/grafana/pull/44161), [@ashharrison90](https://github.com/ashharrison90) +- **SQLStore:** Prevent concurrent migrations. [#44101](https://github.com/grafana/grafana/pull/44101), [@papagian](https://github.com/papagian) +- **SSE:** Add Mode to drop NaN/Inf/Null in Reduction operations. [#43583](https://github.com/grafana/grafana/pull/43583), [@kylebrandt](https://github.com/kylebrandt) +- **Setting:** Support configuring feature toggles with bools instead of just passing an array. [#43326](https://github.com/grafana/grafana/pull/43326), [@bergquist](https://github.com/bergquist) +- **TimeSeries:** Add support for negative Y and constant transform. [#44774](https://github.com/grafana/grafana/pull/44774), [@dprokop](https://github.com/dprokop) +- **Transformations:** Add 'JSON' field type to ConvertFieldTypeTransformer. [#42624](https://github.com/grafana/grafana/pull/42624), [@sd2k](https://github.com/sd2k) + +### Bug fixes + +- **Auth:** Guarantee consistency of signed SigV4 headers. [#45054](https://github.com/grafana/grafana/pull/45054), [@wbrowne](https://github.com/wbrowne) +- **CloudWatch:** Fix MetricName resetting on Namespace change. [#44165](https://github.com/grafana/grafana/pull/44165), [@yaelleC](https://github.com/yaelleC) +- **Cloudwatch :** Fixed resetting metric name when changing namespace in Metric Query. [#44612](https://github.com/grafana/grafana/pull/44612), [@yaelleC](https://github.com/yaelleC) +- **Explore:** Avoid locking timepicker when range is inverted. [#44790](https://github.com/grafana/grafana/pull/44790), [@Elfo404](https://github.com/Elfo404) +- **Instrumentation:** Fix HTTP request instrumentation of authentication failures. [#44234](https://github.com/grafana/grafana/pull/44234), [@marefr](https://github.com/marefr) +- **LibraryPanels:** Prevent long descriptions and names from obscuring the delete button. [#45190](https://github.com/grafana/grafana/pull/45190), [@zuchka](https://github.com/zuchka) +- **OAuth:** Fix parsing of ID token if header contains non-string value. [#44159](https://github.com/grafana/grafana/pull/44159), [@marefr](https://github.com/marefr) +- **Panel Edit:** Visualization search now works correctly with special characters. [#45137](https://github.com/grafana/grafana/pull/45137), [@ashharrison90](https://github.com/ashharrison90) +- **Provisioning:** Fix duplicate validation when multiple organizations have been configured. [#44151](https://github.com/grafana/grafana/pull/44151), [@marefr](https://github.com/marefr) +- **QueryField:** Fix issue with undo history when suggestion is inserted (#28656). [#39114](https://github.com/grafana/grafana/pull/39114), [@glintik](https://github.com/glintik) +- **TablePanel:** Do not prefix columns with frame name if multiple frames and override active. [#45174](https://github.com/grafana/grafana/pull/45174), [@mdvictor](https://github.com/mdvictor) + +### Deprecations + +AngularJS plugin support is now in a deprecated state, meaning it will be removed in a future release. Currently, that is planned for version 10 (in 2023). The documentation site has an [article](https://grafana.com/docs/grafana/next/developers/angular_deprecation/) with more details on why, when, and how. Issue [#45149](https://github.com/grafana/grafana/issues/45149) diff --git a/docs/sources/release-notes/release-notes-8-4-1.md b/docs/sources/release-notes/release-notes-8-4-1.md new file mode 100644 index 00000000000..6b95dae3adf --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-1.md @@ -0,0 +1,18 @@ ++++ +title = "Release notes for Grafana 8.4.1" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.1 + +### Features and enhancements + +- **Cloudwatch:** Add support for AWS/PrivateLink\* metrics and dimensions. [#45515](https://github.com/grafana/grafana/pull/45515), [@szymonpk](https://github.com/szymonpk) +- **Configuration:** Add ability to customize okta login button name and icon. [#44079](https://github.com/grafana/grafana/pull/44079), [@DanCech](https://github.com/DanCech) +- **Tempo:** Switch out Select with AsyncSelect component to get loading state in Tempo Search. [#45110](https://github.com/grafana/grafana/pull/45110), [@CatPerry](https://github.com/CatPerry) + +### Bug fixes + +- **Alerting:** Fix migrations by making send_alerts_to field nullable. [#45572](https://github.com/grafana/grafana/pull/45572), [@santihernandezc](https://github.com/santihernandezc) diff --git a/docs/sources/visualizations/logs-panel.md b/docs/sources/visualizations/logs-panel.md index 179a25e8fbb..e16afd9d692 100644 --- a/docs/sources/visualizations/logs-panel.md +++ b/docs/sources/visualizations/logs-panel.md @@ -17,7 +17,7 @@ To limit the number of lines rendered, you can use the **Max data points** setti ## Log level -For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we parse the log to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions]({{< relref "../explore/_index.md#log-level" >}}). +For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions]({{< relref "../explore/_index.md#log-level" >}}). ## Log details diff --git a/docs/sources/whatsnew/whats-new-in-v8-4.md b/docs/sources/whatsnew/whats-new-in-v8-4.md index 4a86ffcd536..c9d86fb8ac6 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-4.md +++ b/docs/sources/whatsnew/whats-new-in-v8-4.md @@ -24,9 +24,9 @@ You can now share links to your playlists the same way as with dashboards, to ea {{< figure src="/static/img/docs/dashboards/share-playlists-8-4.png" max-width="1200px" caption="Share playlist" >}} -### $**interval and $**interval_ms in panel title +### `$__interval` and `$__interval_ms` in panel title -We’ve expanded the functionality of this existing and popular feature. You can now use $**interval and $**interval_ms in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode. +We’ve expanded the functionality of this existing and popular feature. You can now use `$__interval` and `$__interval_ms` in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode. {{< figure src="/static/img/docs/panels/interval-8-4.png" max-width="1200px" caption="Time intervals" >}} diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts index 4e089a27b79..57dab64ceec 100644 --- a/e2e/various-suite/explore.spec.ts +++ b/e2e/various-suite/explore.spec.ts @@ -19,6 +19,28 @@ e2e.scenario({ cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + cy.location().then((loc) => { + const params = new URLSearchParams(loc.search); + const leftJSON = JSON.parse(params.get('left')); + expect(leftJSON.range.to).to.equal('now'); + expect(leftJSON.range.from).to.equal('now-1h'); + + cy.get('body').click(); + cy.get('body').type('t{leftarrow}'); + + cy.location().then((locPostKeypress) => { + const params = new URLSearchParams(locPostKeypress.search); + const leftJSON = JSON.parse(params.get('left')); + // be sure the keypress affected the time window + expect(leftJSON.range.to).to.not.equal('now'); + expect(leftJSON.range.from).to.not.equal('now-1h'); + // be sure the url does not contain dashboard range values + // eslint wants this to be a function, so we use this instead of to.be.false + expect(params.has('to')).to.equal(false); + expect(params.has('from')).to.equal(false); + }); + }); + const canvases = e2e().get('canvas'); canvases.should('have.length', 1); }, diff --git a/jest.config.js b/jest.config.js index 8218dcb3be6..418b115f570 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,7 +13,7 @@ module.exports = { 'node_modules/(?!(ol)/)', // <- exclude the open layers library ], moduleDirectories: ['node_modules', 'public'], - roots: ['/public/app', '/public/test', '/packages', '/scripts'], + roots: ['/public/app', '/public/test', '/packages'], testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], setupFiles: ['jest-canvas-mock', './public/test/jest-shim.ts', './public/test/jest-setup.ts'], diff --git a/latest.json b/latest.json index d325bb2abf8..2351b122bce 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.3.6", - "testing": "8.4.0-beta1" + "stable": "8.4.1", + "testing": "8.4.1" } diff --git a/log b/log deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/package.json b/package.json index 08ac67a5423..3a96b5bac55 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "devDependencies": { "@babel/core": "7.17.0", "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7", - "@babel/plugin-proposal-object-rest-spread": "7.16.7", + "@babel/plugin-proposal-object-rest-spread": "7.17.3", "@babel/plugin-proposal-optional-chaining": "7.16.7", "@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/plugin-transform-react-constant-elements": "7.16.7", @@ -93,9 +93,9 @@ "@microsoft/api-extractor": "7.19.4", "@pmmmwh/react-refresh-webpack-plugin": "0.5.4", "@react-types/button": "3.4.1", - "@react-types/menu": "3.4.1", + "@react-types/menu": "3.5.1", "@react-types/overlays": "3.5.1", - "@react-types/shared": "3.10.1", + "@react-types/shared": "3.11.1", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@swc/core": "1.2.136", "@swc/helpers": "0.3.2", @@ -126,7 +126,6 @@ "@types/lingui__macro": "^3", "@types/lodash": "4.14.149", "@types/logfmt": "^1.2.1", - "@types/lru-cache": "^5.1.0", "@types/mousetrap": "1.6.3", "@types/node": "16.11.22", "@types/papaparse": "5.3.2", @@ -158,7 +157,7 @@ "@typescript-eslint/parser": "5.11.0", "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", "autoprefixer": "10.4.2", - "axios": "0.25.0", + "axios": "0.26.0", "babel-jest": "27.5.0", "babel-loader": "8.2.3", "babel-plugin-angularjs-annotate": "0.10.0", @@ -169,11 +168,11 @@ "cypress": "9.3.1", "enzyme": "3.11.0", "enzyme-to-json": "3.6.2", - "eslint": "8.8.0", - "eslint-config-prettier": "8.3.0", + "eslint": "8.9.0", + "eslint-config-prettier": "8.4.0", "eslint-plugin-jest": "^26.1.0", - "eslint-plugin-jsdoc": "37.8.0", - "eslint-plugin-lodash": "^7.2.0", + "eslint-plugin-jsdoc": "37.9.1", + "eslint-plugin-lodash": "7.4.0", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-react": "7.28.0", "eslint-plugin-react-hooks": "4.3.0", @@ -213,7 +212,7 @@ "redux-mock-store": "1.5.4", "rimraf": "3.0.2", "sass": "1.49.7", - "sass-loader": "12.4.0", + "sass-loader": "12.6.0", "sinon": "13.0.1", "style-loader": "3.3.1", "stylelint": "14.5.0", @@ -234,7 +233,7 @@ }, "dependencies": { "@emotion/css": "11.7.1", - "@emotion/react": "11.7.1", + "@emotion/react": "11.8.1", "@grafana/aws-sdk": "0.0.33", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", @@ -255,7 +254,7 @@ "@react-aria/button": "3.3.4", "@react-aria/dialog": "3.1.4", "@react-aria/focus": "3.5.0", - "@react-aria/interactions": "3.7.0", + "@react-aria/interactions": "3.8.1", "@react-aria/menu": "3.3.0", "@react-aria/overlays": "3.7.3", "@react-aria/utils": "3.11.0", @@ -309,7 +308,7 @@ "lezer-tree": "0.13.2", "lodash": "4.17.21", "logfmt": "^1.3.2", - "lru-cache": "6.0.0", + "lru-cache": "7.3.1", "memoize-one": "6.0.0", "moment": "2.29.1", "moment-timezone": "0.5.34", @@ -335,7 +334,7 @@ "react-draggable": "4.4.4", "react-grid-layout": "1.3.3", "react-highlight-words": "0.17.0", - "react-hook-form": "7.5.3", + "react-hook-form": "7.27.0", "react-inlinesvg": "2.3.0", "react-loadable": "5.5.0", "react-moveable": "0.30.3", diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 0e8b2c1da88..73a4a39c323 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -285,15 +285,24 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform }; } -// function incrRound(num: number, incr: number) { -// return Math.round(num / incr) * incr; -// } +/** + * @internal + */ +export function incrRound(num: number, incr: number) { + return Math.round(num / incr) * incr; +} -// function incrRoundUp(num: number, incr: number) { -// return Math.ceil(num / incr) * incr; -// } +/** + * @internal + */ +export function incrRoundUp(num: number, incr: number) { + return Math.ceil(num / incr) * incr; +} -function incrRoundDn(num: number, incr: number) { +/** + * @internal + */ +export function incrRoundDn(num: number, incr: number) { return Math.floor(num / incr) * incr; } diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index ed2066063e6..e8254b1949a 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -28,6 +28,7 @@ export enum DataTransformerID { prepareTimeSeries = 'prepareTimeSeries', convertFieldType = 'convertFieldType', fieldLookup = 'fieldLookup', + heatmap = 'heatmap', spatial = 'spatial', extractFields = 'extractFields', } diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index c07e212e9cd..cca149159ca 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -83,7 +83,7 @@ export type OAuth = * * @public */ -export type OAuthSettings = Partial>; +export type OAuthSettings = Partial>; /** * Describes all the different Grafana configuration values available for an instance. @@ -138,4 +138,5 @@ export interface GrafanaConfig { geomapDefaultBaseLayer?: MapLayerOptions; geomapDisableCustomBaseLayer?: boolean; unifiedAlertingEnabled: boolean; + angularSupportEnabled: boolean; } diff --git a/packages/grafana-data/src/types/dataFrameTypes.ts b/packages/grafana-data/src/types/dataFrameTypes.ts index 9add0f27abf..f4faa9fda88 100644 --- a/packages/grafana-data/src/types/dataFrameTypes.ts +++ b/packages/grafana-data/src/types/dataFrameTypes.ts @@ -8,4 +8,17 @@ export enum DataFrameType { TimeSeriesWide = 'timeseries-wide', TimeSeriesLong = 'timeseries-long', TimeSeriesMany = 'timeseries-many', + + /** + * First field is X, the rest are bucket values + */ + HeatmapBuckets = 'heatmap-buckets', + + /** + * Explicit fields for: + * xMin, yMin, count, ... + * + * All values in the grid exist and have regular spacing + */ + HeatmapScanlines = 'heatmap-scanlines', } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a5f13a7de0f..95bb511c0e9 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -23,6 +23,7 @@ export interface FeatureToggles { database_metrics?: boolean; dashboardPreviews?: boolean; dashboardPreviewsScheduler?: boolean; + dashboardPreviewsAdmin?: boolean; ['live-config']?: boolean; ['live-pipeline']?: boolean; ['live-service-web-worker']?: boolean; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index d4c885f11a5..781a6610270 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -20,6 +20,7 @@ export interface NavModelItem { onClick?: () => void; menuItemType?: NavMenuItemType; highlightText?: string; + highlightId?: string; tabSuffix?: ComponentType<{ className?: string }>; } diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index d9258c84c71..99c63fd66a3 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -41,6 +41,7 @@ export class GrafanaBootConfig implements GrafanaConfig { alertingErrorOrTimeout = ''; alertingNoDataOrNullValues = ''; alertingMinInterval = 1; + angularSupportEnabled = false; authProxyEnabled = false; exploreEnabled = false; ldapEnabled = false; @@ -65,6 +66,13 @@ export class GrafanaBootConfig implements GrafanaConfig { featureToggles: FeatureToggles = {}; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; + dashboardPreviews: { + systemRequirements: { + met: boolean; + requiredImageRendererPluginVersion: string; + }; + thumbnailsExist: boolean; + } = { systemRequirements: { met: false, requiredImageRendererPluginVersion: '' }, thumbnailsExist: false }; rendererVersion = ''; http2Enabled = false; dateFormats?: SystemDateFormatSettings; diff --git a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue index 4f97a4230d0..2f8f169ed7e 100644 --- a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue +++ b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue @@ -16,6 +16,7 @@ import ( ptable "github.com/grafana/grafana/public/app/plugins/panel/table:grafanaschema" ptext "github.com/grafana/grafana/public/app/plugins/panel/text:grafanaschema" ptimeseries "github.com/grafana/grafana/public/app/plugins/panel/timeseries:grafanaschema" + pheatmap_new "github.com/grafana/grafana/public/app/plugins/panel/heatmap-new:grafanaschema" ) // Family composes the base dashboard scuemata family with all Grafana core plugins - @@ -40,5 +41,6 @@ Family: dashboard.Family & { text: ptext.Panel table: ptable.Panel timeseries: ptimeseries.Panel + "heatmap-new": pheatmap_new.Panel } } \ No newline at end of file diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 149fac977ac..7d6935c56cf 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -31,7 +31,7 @@ ], "dependencies": { "@emotion/css": "11.7.1", - "@emotion/react": "11.7.1", + "@emotion/react": "11.8.1", "@grafana/aws-sdk": "0.0.33", "@grafana/data": "8.5.0-pre", "@grafana/e2e-selectors": "8.5.0-pre", @@ -74,7 +74,7 @@ "react-dom": "17.0.2", "react-dropzone": "11.5.1", "react-highlight-words": "0.17.0", - "react-hook-form": "7.5.3", + "react-hook-form": "7.27.0", "react-inlinesvg": "2.3.0", "react-popper": "2.2.5", "react-popper-tooltip": "^4.3.1", @@ -169,7 +169,7 @@ "rollup": "2.67.1", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", - "sass-loader": "12.4.0", + "sass-loader": "12.6.0", "storybook-dark-mode": "1.0.8", "style-loader": "3.3.1", "terser-webpack-plugin": "5.3.1", diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx index b25ecc025f8..c6e73b7fe2b 100644 --- a/packages/grafana-ui/src/components/Alert/Alert.tsx +++ b/packages/grafana-ui/src/components/Alert/Alert.tsx @@ -19,6 +19,7 @@ export interface Props extends HTMLAttributes { elevated?: boolean; buttonContent?: React.ReactNode | string; bottomSpacing?: number; + topSpacing?: number; } function getIconFromSeverity(severity: AlertVariant): string { @@ -37,11 +38,22 @@ function getIconFromSeverity(severity: AlertVariant): string { export const Alert = React.forwardRef( ( - { title, onRemove, children, buttonContent, elevated, bottomSpacing, className, severity = 'error', ...restProps }, + { + title, + onRemove, + children, + buttonContent, + elevated, + bottomSpacing, + topSpacing, + className, + severity = 'error', + ...restProps + }, ref ) => { const theme = useTheme2(); - const styles = getStyles(theme, severity, elevated, bottomSpacing); + const styles = getStyles(theme, severity, elevated, bottomSpacing, topSpacing); return (
( Alert.displayName = 'Alert'; -const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: boolean, bottomSpacing?: number) => { +const getStyles = ( + theme: GrafanaTheme2, + severity: AlertVariant, + elevated?: boolean, + bottomSpacing?: number, + topSpacing?: number +) => { const color = theme.colors[severity]; const borderRadius = theme.shape.borderRadius(); @@ -92,6 +110,7 @@ const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: bool background: ${theme.colors.background.secondary}; box-shadow: ${elevated ? theme.shadows.z3 : theme.shadows.z1}; margin-bottom: ${theme.spacing(bottomSpacing ?? 2)}; + margin-top: ${theme.spacing(topSpacing ?? 0)}; &:before { content: ''; diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx index 94b995ccc99..16efc2b43c3 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import { BigValue, Props, BigValueColorMode, BigValueGraphMode } from './BigValue'; +import { render, screen } from '@testing-library/react'; +import { BigValue, BigValueColorMode, BigValueGraphMode, Props } from './BigValue'; import { createTheme } from '@grafana/data'; function getProps(propOverrides?: Partial): Props { @@ -21,22 +21,12 @@ function getProps(propOverrides?: Partial): Props { return props; } -const setup = (propOverrides?: object) => { - const props = getProps(propOverrides); - const wrapper = shallow(); - const instance = wrapper.instance() as BigValue; - - return { - instance, - wrapper, - }; -}; - describe('BigValue', () => { describe('Render with basic options', () => { it('should render', () => { - const { wrapper } = setup(); - expect(wrapper).toMatchSnapshot(); + render(); + + expect(screen.getByText('25')).toBeInTheDocument(); }); }); }); diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx index ff222b5d60e..864c2547199 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx @@ -1,8 +1,6 @@ -// Library import React, { PureComponent } from 'react'; import { DisplayValue, DisplayValueAlignmentFactors, FieldSparkline, TextDisplayOptions } from '@grafana/data'; -// Types import { Themeable2 } from '../../types'; import { buildLayout } from './BigValueLayout'; import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay'; diff --git a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap deleted file mode 100644 index 5acf66fb54e..00000000000 --- a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BigValue Render with basic options should render 1`] = ` -
-
- -
-
-`; diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx index 633902d32df..1e2a72d8f81 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx @@ -1,33 +1,39 @@ import React from 'react'; -import { render } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { CallToActionCard } from './CallToActionCard'; +import { expect } from '../../../../../public/test/lib/common'; describe('CallToActionCard', () => { describe('rendering', () => { - it('when no message and footer provided', () => { - const tree = render(Click me} />); - expect(tree).toMatchSnapshot(); + it('should render callToActionElement', () => { + render(Click me} />); + expect(screen.getByRole('link', { name: 'Click me' })).toBeInTheDocument(); }); - it('when message and no footer provided', () => { - const tree = render( - Click me} - /> + it('should render message when provided', () => { + render( + Click me} /> ); - expect(tree).toMatchSnapshot(); + expect(screen.getByText('Click button below')).toBeInTheDocument(); }); - it('when message and footer provided', () => { - const tree = render( + it('should render footer when provided', () => { + render( + Click me} /> + ); + expect(screen.getByText('footer content')).toBeInTheDocument(); + }); + + it('should render both message and footer when provided', () => { + render( Click me} /> ); - expect(tree).toMatchSnapshot(); + expect(screen.getByText('Click button below')).toBeInTheDocument(); + expect(screen.getByText('footer content')).toBeInTheDocument(); }); }); }); diff --git a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap deleted file mode 100644 index 511fd516da3..00000000000 --- a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap +++ /dev/null @@ -1,52 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CallToActionCard rendering when message and footer provided 1`] = ` -
-
- Click button bellow -
- - Click me - -
- footer content -
-
-`; - -exports[`CallToActionCard rendering when message and no footer provided 1`] = ` -
-
- Click button bellow -
- - Click me - -
-`; - -exports[`CallToActionCard rendering when no message and footer provided 1`] = ` - -`; diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx index e3117ec9336..c9e4d9cd4f3 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx @@ -1,34 +1,72 @@ import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + import { ConfirmButton } from './ConfirmButton'; -import { mount, ShallowWrapper } from 'enzyme'; -import { Button } from '../Button'; +import { expect } from '../../../../../public/test/lib/common'; describe('ConfirmButton', () => { - let wrapper: any; - let deleted: any; - - beforeAll(() => { - deleted = false; - - function deleteItem() { - deleted = true; - } - - wrapper = mount( - deleteItem()}> + it('should show confirm delete when clicked', () => { + const onConfirm = jest.fn(); + render( + Delete ); + + // Confirm button should not be visible before clicking the Delete button + expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + // Confirm button should now be visible + expect(screen.getByRole('button', { name: 'Confirm delete' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'Confirm delete' })); + expect(onConfirm).toHaveBeenCalled(); + + // Confirm button should be visible if closeOnConfirm is false + expect(screen.queryByRole('button', { name: 'Confirm delete' })).toBeInTheDocument(); }); - it('should show confirm delete when clicked', () => { - expect(deleted).toBe(false); - wrapper - .find(Button) - .findWhere((n: ShallowWrapper) => { - return n.text() === 'Confirm delete' && n.type() === Button; - }) - .simulate('click'); - expect(deleted).toBe(true); + it('should hide confirm delete when closeOnConfirm is true', () => { + render( + {}} closeOnConfirm={true}> + Delete + + ); + + // Confirm button should not be visible before clicking the Delete button + expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + // Confirm button should now be visible + expect(screen.getByRole('button', { name: 'Confirm delete' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'Confirm delete' })); + + // Confirm button should not be visible if closeOnConfirm is true + expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(); + }); + + it('should show cancel when clicked', () => { + const onCancel = jest.fn(); + render( + {}}> + Delete + + ); + + // Cancel button should not be visible before clicking the Delete button + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + // Cancel button should now be visible + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalled(); + + // Cancel button should not be visible after click + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx index e5c720b7b0d..50a9482c9af 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx @@ -1,23 +1,34 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render, screen, within } from '@testing-library/react'; + import { ConfirmModal } from './ConfirmModal'; describe('ConfirmModal', () => { - it('renders without error', () => { - mount( + it('should render correct title, body, dismiss-, alternative- and confirm-text', () => { + render( {}} onDismiss={() => {}} + onAlternative={() => {}} /> ); + + expect(screen.getByRole('heading', { name: 'Some Title' })).toBeInTheDocument(); + expect(screen.getByText('Some Body')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dismiss Text' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Alternative Text' })).toBeInTheDocument(); + const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' }); + expect(within(button).getByText('Please Confirm')).toBeInTheDocument(); }); - it('renders nothing by default or when isOpen is false', () => { - const wrapper = mount( + it('should render nothing when isOpen is false', () => { + render( { onDismiss={() => {}} /> ); - expect(wrapper.html()).toBe(''); - wrapper.setProps({ ...wrapper.props(), isOpen: false }); - expect(wrapper.html()).toBe(''); - }); - - it('renders correct contents', () => { - const wrapper = mount( - {}} - onDismiss={() => {}} - /> - ); - - expect(wrapper.contains('Some Title')).toBeTruthy(); - expect(wrapper.contains('Content')).toBeTruthy(); - expect(wrapper.contains('Confirm')).toBeTruthy(); + expect(screen.queryByRole('heading', { name: 'Some Title' })).not.toBeInTheDocument(); + expect(screen.queryByText('Some Body')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Dismiss Text' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Alternative Text' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Confirm Modal Danger Button' })).not.toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx index d2c64cd404e..99d5bf676c6 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useRef, useState } from 'react'; import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + import { Modal } from '../Modal/Modal'; import { IconName } from '../../types/icon'; import { Button } from '../Button'; import { useStyles2 } from '../../themes'; -import { GrafanaTheme2 } from '@grafana/data'; import { HorizontalGroup, Input } from '..'; -import { selectors } from '@grafana/e2e-selectors'; export interface ConfirmModalProps { /** Toggle modal's open/closed state */ diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index dacd001a8a1..17134a109e7 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -9,6 +9,7 @@ import { useStyles2 } from '../../../themes'; export interface RadioButtonGroupProps { value?: T; + id?: string; disabled?: boolean; disabledOptions?: T[]; options: Array>; @@ -28,6 +29,7 @@ export function RadioButtonGroup({ disabled, disabledOptions, size = 'md', + id, className, fullWidth = false, autoFocus = false, @@ -52,8 +54,9 @@ export function RadioButtonGroup({ }, [onClick] ); - const id = uniqueId('radiogroup-'); - const groupName = useRef(id); + + const internalId = id ?? uniqueId('radiogroup-'); + const groupName = useRef(internalId); const styles = useStyles2(getStyles); const activeButtonRef = useRef(null); @@ -76,7 +79,7 @@ export function RadioButtonGroup({ aria-label={o.ariaLabel} onChange={handleOnChange(o)} onClick={handleOnClick(o)} - id={`option-${o.value}-${id}`} + id={`option-${o.value}-${internalId}`} name={groupName.current} description={o.description} fullWidth={fullWidth} diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 053539acbe1..ba5cd2b2b9f 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -69,6 +69,7 @@ function getStyles(theme: GrafanaTheme2) { transition: opacity 0.3s; z-index: ${theme.zIndex.tooltip}; max-width: 400px; + overflow-wrap: break-word; &[data-popper-interactive='false'] { pointer-events: none; diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx index 48dad2789eb..96e6a506413 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx @@ -23,7 +23,7 @@ export const VizTooltipContainer: React.FC = ({ position: { x: positionX, y: positionY }, offset: { x: offsetX, y: offsetY }, children, - allowPointerEvents, + allowPointerEvents = false, className, ...otherProps }) => { diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index ce1308cecb6..c68c7169f87 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -18,7 +18,6 @@ "@types/prop-types": "^15", "@types/react": "17.0.39", "@types/react-icons": "2.2.7", - "@types/recompose": "0.30.10", "@types/reselect": "2.2.0", "@types/tinycolor2": "1.4.3", "enzyme": "3.11.0", @@ -46,7 +45,6 @@ "react": "17.0.2", "react-dom": "17.0.2", "react-icons": "2.2.7", - "recompose": "0.30.0", "reselect": "4.1.5", "tinycolor2": "1.4.2", "tslib": "2.3.1", diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js index 09146b8e459..70cc7b87420 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; import { Popover } from '../common/Popover'; import SpanBar from './SpanBar'; @@ -79,9 +80,13 @@ describe('', () => { const { onMouseOver, onMouseLeave } = wrapper.find('[data-test-id="SpanBar--wrapper"]').props(); const labelElm = wrapper.find('[data-test-id="SpanBar--label"]'); expect(labelElm.text()).toBe(shortLabel); - onMouseOver(); + act(() => { + onMouseOver(); + }); expect(labelElm.text()).toBe(longLabel); - onMouseLeave(); + act(() => { + onMouseLeave(); + }); expect(labelElm.text()).toBe(shortLabel); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx index c78db4b23ae..097bd362bda 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx @@ -15,8 +15,7 @@ import cx from 'classnames'; import { css } from '@emotion/css'; import { groupBy as _groupBy } from 'lodash'; -import React from 'react'; -import { compose, onlyUpdateForKeys, withProps, withState } from 'recompose'; +import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { autoColor } from '../Theme'; @@ -90,7 +89,7 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -type TCommonProps = { +type Props = { color: string; onClick?: (evt: React.MouseEvent) => void; viewEnd: number; @@ -107,39 +106,32 @@ type TCommonProps = { span: TraceSpan; className?: string; labelClassName?: string; -}; - -type TInnerProps = { - label: string; - setLongLabel: () => void; - setShortLabel: () => void; -} & TCommonProps; - -type TOuterProps = { longLabel: string; shortLabel: string; -} & TCommonProps; +}; function toPercent(value: number) { return `${(value * 100).toFixed(1)}%`; } -function SpanBar(props: TInnerProps) { - const { - viewEnd, - viewStart, - getViewedBounds, - color, - label, - onClick, - setLongLabel, - setShortLabel, - rpc, - traceStartTime, - span, - className, - labelClassName, - } = props; +function SpanBar({ + viewEnd, + viewStart, + getViewedBounds, + color, + shortLabel, + longLabel, + onClick, + rpc, + traceStartTime, + span, + className, + labelClassName, +}: Props) { + const [label, setLabel] = useState(shortLabel); + const setShortLabel = () => setLabel(shortLabel); + const setLongLabel = () => setLabel(longLabel); + // group logs based on timestamps const logGroups = _groupBy(span.logs, (log) => { const posPercent = getViewedBounds(log.timestamp, log.timestamp).start; @@ -196,21 +188,4 @@ function SpanBar(props: TInnerProps) { ); } -export default compose( - withState('label', 'setLabel', (props: { shortLabel: string }) => props.shortLabel), - withProps( - ({ - setLabel, - shortLabel, - longLabel, - }: { - setLabel: (label: string) => void; - shortLabel: string; - longLabel: string; - }) => ({ - setLongLabel: () => setLabel(longLabel), - setShortLabel: () => setLabel(shortLabel), - }) - ), - onlyUpdateForKeys(['label', 'rpc', 'viewStart', 'viewEnd']) -)(SpanBar); +export default React.memo(SpanBar); diff --git a/packages/jaeger-ui-components/src/index.ts b/packages/jaeger-ui-components/src/index.ts index 64ad6a2b383..f5737eff1f5 100644 --- a/packages/jaeger-ui-components/src/index.ts +++ b/packages/jaeger-ui-components/src/index.ts @@ -6,9 +6,3 @@ export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailS export { default as transformTraceData } from './model/transform-trace-data'; export { default as filterSpans } from './utils/filter-spans'; export * from './Theme'; - -import { onlyUpdateForKeys } from 'recompose'; - -export default { - onlyUpdateForKeys, -} as any; diff --git a/pkg/api/roles.go b/pkg/api/accesscontrol.go similarity index 55% rename from pkg/api/roles.go rename to pkg/api/accesscontrol.go index 8ec62c68ea7..5215869edf8 100644 --- a/pkg/api/roles.go +++ b/pkg/api/accesscontrol.go @@ -1,8 +1,10 @@ package api import ( + "fmt" + "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" ) @@ -29,49 +31,49 @@ const ( // API related scopes var ( - ScopeProvisionersAll = accesscontrol.Scope("provisioners", "*") - ScopeProvisionersDashboards = accesscontrol.Scope("provisioners", "dashboards") - ScopeProvisionersPlugins = accesscontrol.Scope("provisioners", "plugins") - ScopeProvisionersDatasources = accesscontrol.Scope("provisioners", "datasources") - ScopeProvisionersNotifications = accesscontrol.Scope("provisioners", "notifications") + ScopeProvisionersAll = ac.Scope("provisioners", "*") + ScopeProvisionersDashboards = ac.Scope("provisioners", "dashboards") + ScopeProvisionersPlugins = ac.Scope("provisioners", "plugins") + ScopeProvisionersDatasources = ac.Scope("provisioners", "datasources") + ScopeProvisionersNotifications = ac.Scope("provisioners", "notifications") - ScopeDatasourcesAll = accesscontrol.Scope("datasources", "*") - ScopeDatasourceID = accesscontrol.Scope("datasources", "id", accesscontrol.Parameter(":id")) - ScopeDatasourceUID = accesscontrol.Scope("datasources", "uid", accesscontrol.Parameter(":uid")) - ScopeDatasourceName = accesscontrol.Scope("datasources", "name", accesscontrol.Parameter(":name")) + ScopeDatasourcesAll = ac.Scope("datasources", "*") + ScopeDatasourceID = ac.Scope("datasources", "id", ac.Parameter(":id")) + ScopeDatasourceUID = ac.Scope("datasources", "uid", ac.Parameter(":uid")) + ScopeDatasourceName = ac.Scope("datasources", "name", ac.Parameter(":name")) ) // declareFixedRoles declares to the AccessControl service fixed roles and their // grants to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" // that HTTPServer needs func (hs *HTTPServer) declareFixedRoles() error { - provisioningWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + provisioningWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:provisioning:writer", DisplayName: "Provisioning writer", Description: "Reload provisioning.", Group: "Provisioning", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { Action: ActionProvisioningReload, Scope: ScopeProvisionersAll, }, }, }, - Grants: []string{accesscontrol.RoleGrafanaAdmin}, + Grants: []string{ac.RoleGrafanaAdmin}, } - datasourcesExplorerRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesExplorerRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 4, Name: "fixed:datasources:explorer", DisplayName: "Data source explorer", Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.", Group: "Data sources", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { - Action: accesscontrol.ActionDatasourcesExplore, + Action: ac.ActionDatasourcesExplore, }, }, }, @@ -82,14 +84,14 @@ func (hs *HTTPServer) declareFixedRoles() error { datasourcesExplorerRole.Grants = append(datasourcesExplorerRole.Grants, string(models.ROLE_VIEWER)) } - datasourcesReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:datasources:reader", DisplayName: "Data source reader", Description: "Read and query all data sources.", Group: "Data sources", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { Action: ActionDatasourcesRead, Scope: ScopeDatasourcesAll, @@ -103,14 +105,14 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - datasourcesWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:datasources:writer", DisplayName: "Data source writer", Description: "Create, update, delete, read, or query data sources.", Group: "Data sources", - Permissions: accesscontrol.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: ac.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []ac.Permission{ { Action: ActionDatasourcesWrite, Scope: ScopeDatasourcesAll, @@ -127,14 +129,14 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - datasourcesIdReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesIdReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 4, Name: "fixed:datasources.id:reader", DisplayName: "Data source ID reader", Description: "Read the ID of a data source based on its name.", Group: "Infrequently used", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { Action: ActionDatasourcesIDRead, Scope: ScopeDatasourcesAll, @@ -144,14 +146,14 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_VIEWER)}, } - datasourcesCompatibilityReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesCompatibilityReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:datasources:compatibility:querier", DisplayName: "Data source compatibility querier", Description: "Only used for open source compatibility. Query data sources.", Group: "Infrequently used", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ {Action: ActionDatasourcesQuery}, {Action: ActionDatasourcesRead}, }, @@ -159,29 +161,29 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_VIEWER)}, } - orgReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + orgReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ 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{ + Permissions: []ac.Permission{ {Action: ActionOrgsRead}, {Action: ActionOrgsQuotasRead}, }, }, - Grants: []string{string(models.ROLE_VIEWER), accesscontrol.RoleGrafanaAdmin}, + Grants: []string{string(models.ROLE_VIEWER), ac.RoleGrafanaAdmin}, } - orgWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + orgWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ 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(orgReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ {Action: ActionOrgsPreferencesRead}, {Action: ActionOrgsWrite}, {Action: ActionOrgsPreferencesWrite}, @@ -190,71 +192,71 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - orgMaintainerRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + orgMaintainerRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ 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{ + Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ {Action: ActionOrgsCreate}, {Action: ActionOrgsWrite}, {Action: ActionOrgsDelete}, {Action: ActionOrgsQuotasWrite}, }), }, - Grants: []string{string(accesscontrol.RoleGrafanaAdmin)}, + Grants: []string{string(ac.RoleGrafanaAdmin)}, } teamCreatorGrants := []string{string(models.ROLE_ADMIN)} if hs.Cfg.EditorsCanAdmin { teamCreatorGrants = append(teamCreatorGrants, string(models.ROLE_EDITOR)) } - teamsCreatorRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + teamsCreatorRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Name: "fixed:teams:creator", DisplayName: "Team creator", Description: "Create teams and read organisation users (required to manage the created teams).", Group: "Teams", Version: 2, - Permissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionTeamsCreate}, - {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + Permissions: []ac.Permission{ + {Action: ac.ActionTeamsCreate}, + {Action: ac.ActionOrgUsersRead, Scope: ac.ScopeUsersAll}, }, }, Grants: teamCreatorGrants, } - teamsWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + teamsWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Name: "fixed:teams:writer", DisplayName: "Team writer", Description: "Create, read, write, or delete a team as well as controlling team memberships.", Group: "Teams", Version: 2, - Permissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionTeamsCreate}, - {Action: accesscontrol.ActionTeamsDelete, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsWrite, Scope: accesscontrol.ScopeTeamsAll}, + Permissions: []ac.Permission{ + {Action: ac.ActionTeamsCreate}, + {Action: ac.ActionTeamsDelete, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsPermissionsRead, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsPermissionsWrite, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsRead, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsWrite, Scope: ac.ScopeTeamsAll}, }, }, Grants: []string{string(models.ROLE_ADMIN)}, } - annotationsReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + annotationsReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Name: "fixed:annotations:reader", DisplayName: "Annotation reader", Description: "Read annotations and tags", Group: "Annotations", Version: 1, - Permissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionAnnotationsRead, Scope: accesscontrol.ScopeAnnotationsAll}, - {Action: accesscontrol.ActionAnnotationsTagsRead, Scope: accesscontrol.ScopeAnnotationsTagsAll}, + Permissions: []ac.Permission{ + {Action: ac.ActionAnnotationsRead, Scope: ac.ScopeAnnotationsAll}, + {Action: ac.ActionAnnotationsTagsRead, Scope: ac.ScopeAnnotationsTagsAll}, }, }, Grants: []string{string(models.ROLE_VIEWER)}, @@ -271,69 +273,96 @@ func (hs *HTTPServer) declareFixedRoles() error { // here is the list of complex evaluators we use in this package // dataSourcesConfigurationAccessEvaluator is used to protect the "Configure > Data sources" tab access -var dataSourcesConfigurationAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionDatasourcesRead), - accesscontrol.EvalAny( - accesscontrol.EvalPermission(ActionDatasourcesCreate), - accesscontrol.EvalPermission(ActionDatasourcesDelete), - accesscontrol.EvalPermission(ActionDatasourcesWrite), +var dataSourcesConfigurationAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionDatasourcesRead), + ac.EvalAny( + ac.EvalPermission(ActionDatasourcesCreate), + ac.EvalPermission(ActionDatasourcesDelete), + ac.EvalPermission(ActionDatasourcesWrite), ), ) // dataSourcesNewAccessEvaluator is used to protect the "Configure > Data sources > New" page access -var dataSourcesNewAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionDatasourcesRead), - accesscontrol.EvalPermission(ActionDatasourcesCreate), - accesscontrol.EvalPermission(ActionDatasourcesWrite), +var dataSourcesNewAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionDatasourcesRead), + ac.EvalPermission(ActionDatasourcesCreate), + ac.EvalPermission(ActionDatasourcesWrite), ) // dataSourcesEditAccessEvaluator is used to protect the "Configure > Data sources > Edit" page access -var dataSourcesEditAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionDatasourcesRead), - accesscontrol.EvalPermission(ActionDatasourcesWrite), +var dataSourcesEditAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionDatasourcesRead), + ac.EvalPermission(ActionDatasourcesWrite), ) // orgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access -var orgPreferencesAccessEvaluator = accesscontrol.EvalAny( - accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionOrgsRead), - accesscontrol.EvalPermission(ActionOrgsWrite), +var orgPreferencesAccessEvaluator = ac.EvalAny( + ac.EvalAll( + ac.EvalPermission(ActionOrgsRead), + ac.EvalPermission(ActionOrgsWrite), ), - accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionOrgsPreferencesRead), - accesscontrol.EvalPermission(ActionOrgsPreferencesWrite), + ac.EvalAll( + ac.EvalPermission(ActionOrgsPreferencesRead), + ac.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) +var orgsAccessEvaluator = ac.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), +var orgsCreateAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionOrgsRead), + ac.EvalPermission(ActionOrgsCreate), ) // teamsAccessEvaluator is used to protect the "Configuration > Teams" page access // grants access to a user when they can either create teams or can read and update a team -var teamsAccessEvaluator = accesscontrol.EvalAny( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate), - accesscontrol.EvalAll( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead), - accesscontrol.EvalAny( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite), - accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite), +var teamsAccessEvaluator = ac.EvalAny( + ac.EvalPermission(ac.ActionTeamsCreate), + ac.EvalAll( + ac.EvalPermission(ac.ActionTeamsRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionTeamsWrite), + ac.EvalPermission(ac.ActionTeamsPermissionsWrite), ), ), ) // teamsEditAccessEvaluator is used to protect the "Configuration > Teams > edit" page access -var teamsEditAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead), - accesscontrol.EvalAny( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate), - accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite), - accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite), +var teamsEditAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ac.ActionTeamsRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionTeamsCreate), + ac.EvalPermission(ac.ActionTeamsWrite), + ac.EvalPermission(ac.ActionTeamsPermissionsWrite), ), ) + +// Metadata helpers +// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource +func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, resource string, id int64) ac.Metadata { + key := fmt.Sprintf("%d", id) + ids := map[string]bool{key: true} + + return hs.getMultiAccessControlMetadata(c, resource, ids)[key] +} + +// getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources +func (hs *HTTPServer) getMultiAccessControlMetadata(c *models.ReqContext, resource string, ids map[string]bool) map[string]ac.Metadata { + if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { + return map[string]ac.Metadata{} + } + + if c.SignedInUser.Permissions == nil { + return map[string]ac.Metadata{} + } + + permissions, ok := c.SignedInUser.Permissions[c.OrgId] + if !ok { + return map[string]ac.Metadata{} + } + + return ac.GetResourcesMetadata(c.Req.Context(), permissions, resource, ids) +} diff --git a/pkg/api/acl.go b/pkg/api/acl.go deleted file mode 100644 index eae0cf21aeb..00000000000 --- a/pkg/api/acl.go +++ /dev/null @@ -1,15 +0,0 @@ -package api - -import ( - "context" - - "github.com/grafana/grafana/pkg/dashboards" - "github.com/grafana/grafana/pkg/models" -) - -// updateDashboardACL updates a dashboard's ACL items. -// -// Stubbable by tests. -var updateDashboardACL = func(ctx context.Context, s dashboards.Store, dashID int64, items []*models.DashboardAcl) error { - return s.UpdateDashboardACLCtx(ctx, dashID, items) -} diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 23efe48f686..15414b6269d 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -239,7 +239,7 @@ func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Respo func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*models.AlertNotification, error) { query := &models.GetAllAlertNotificationsQuery{OrgId: c.OrgId} - if err := hs.SQLStore.GetAllAlertNotifications(c.Req.Context(), query); err != nil { + if err := hs.AlertNotificationService.GetAllAlertNotifications(c.Req.Context(), query); err != nil { return nil, err } @@ -260,7 +260,7 @@ func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Re return response.Error(404, "Alert notification not found", nil) } - if err := hs.SQLStore.GetAlertNotifications(c.Req.Context(), query); err != nil { + if err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), query); err != nil { return response.Error(500, "Failed to get alert notifications", err) } @@ -281,7 +281,7 @@ func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.R return response.Error(404, "Alert notification not found", nil) } - if err := hs.SQLStore.GetAlertNotificationsWithUid(c.Req.Context(), query); err != nil { + if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), query); err != nil { return response.Error(500, "Failed to get alert notifications", err) } @@ -299,7 +299,7 @@ func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Res } cmd.OrgId = c.OrgId - if err := hs.SQLStore.CreateAlertNotificationCommand(c.Req.Context(), &cmd); err != nil { + if err := hs.AlertNotificationService.CreateAlertNotificationCommand(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrAlertNotificationWithSameNameExists) || errors.Is(err, models.ErrAlertNotificationWithSameUIDExists) { return response.Error(409, "Failed to create alert notification", err) } @@ -325,7 +325,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res return response.Error(500, "Failed to update alert notification", err) } - if err := hs.SQLStore.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil { + if err := hs.AlertNotificationService.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrAlertNotificationNotFound) { return response.Error(404, err.Error(), err) } @@ -341,7 +341,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res Id: cmd.Id, } - if err := hs.SQLStore.GetAlertNotifications(c.Req.Context(), &query); err != nil { + if err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), &query); err != nil { return response.Error(500, "Failed to get alert notification", err) } @@ -361,7 +361,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons return response.Error(500, "Failed to update alert notification", err) } - if err := hs.SQLStore.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil { + if err := hs.AlertNotificationService.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrAlertNotificationNotFound) { return response.Error(404, err.Error(), nil) } @@ -373,7 +373,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons Uid: cmd.Uid, } - if err := hs.SQLStore.GetAlertNotificationsWithUid(c.Req.Context(), &query); err != nil { + if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), &query); err != nil { return response.Error(500, "Failed to get alert notification", err) } @@ -390,7 +390,7 @@ func (hs *HTTPServer) fillWithSecureSettingsData(ctx context.Context, cmd *model Id: cmd.Id, } - if err := hs.SQLStore.GetAlertNotifications(ctx, query); err != nil { + if err := hs.AlertNotificationService.GetAlertNotifications(ctx, query); err != nil { return err } @@ -418,7 +418,7 @@ func (hs *HTTPServer) fillWithSecureSettingsDataByUID(ctx context.Context, cmd * Uid: cmd.Uid, } - if err := hs.SQLStore.GetAlertNotificationsWithUid(ctx, query); err != nil { + if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(ctx, query); err != nil { return err } @@ -447,7 +447,7 @@ func (hs *HTTPServer) DeleteAlertNotification(c *models.ReqContext) response.Res Id: notificationId, } - if err := hs.SQLStore.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil { + if err := hs.AlertNotificationService.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrAlertNotificationNotFound) { return response.Error(404, err.Error(), nil) } @@ -463,7 +463,7 @@ func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) respons Uid: web.Params(c.Req)[":uid"], } - if err := hs.SQLStore.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil { + if err := hs.AlertNotificationService.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrAlertNotificationNotFound) { return response.Error(404, err.Error(), nil) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 6da0aa3488c..535cca95ab7 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -26,7 +26,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin - reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin + reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin(hs.SQLStore) reqCanAccessTeams := middleware.AdminOrEditorAndFeatureEnabled(hs.Cfg.EditorsCanAdmin) reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg) redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg) @@ -336,8 +336,11 @@ func (hs *HTTPServer) registerRoutes() { if hs.ThumbService != nil { dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage) - dashboardRoute.Post("/uid/:uid/img/:kind/:theme", hs.ThumbService.SetImage) - dashboardRoute.Put("/uid/:uid/img/:kind/:theme", hs.ThumbService.UpdateThumbnailState) + + if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { + dashboardRoute.Post("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage) + dashboardRoute.Put("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState) + } } dashboardRoute.Post("/calculate-diff", routing.Wrap(hs.CalculateDashboardDiff)) @@ -470,7 +473,7 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats)) adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts)) - if hs.ThumbService != nil { + if hs.ThumbService != nil && hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { adminRoute.Post("/crawler/start", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StartCrawler)) adminRoute.Post("/crawler/stop", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StopCrawler)) adminRoute.Get("/crawler/status", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.CrawlerStatus)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index ce94ba3e77e..f03eb0eb304 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -25,9 +25,11 @@ import ( acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/dashboards" + dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/quota" @@ -276,6 +278,8 @@ type accessControlScenarioContext struct { // cfg is the setting provider cfg *setting.Cfg + + dashboardsStore dashboards.Store } func setAccessControlPermissions(acmock *accesscontrolmock.Mock, perms []*accesscontrol.Permission, org int64) { @@ -347,6 +351,8 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont bus := bus.GetBus() + dashboardsStore := dashboardsstore.ProvideDashboardStore(db) + routeRegister := routing.NewRouteRegister() // Create minimal HTTP Server hs := &HTTPServer{ @@ -358,6 +364,7 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont RouteRegister: routeRegister, SQLStore: db, searchUsersService: searchusers.ProvideUsersService(bus, filters.ProvideOSSSearchUserFilter()), + dashboardService: dashboardservice.ProvideDashboardService(dashboardsStore), } // Defining the accesscontrol service has to be done before registering routes @@ -367,9 +374,9 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont acmock = acmock.WithDisabled() } hs.AccessControl = acmock - teamPermissionService, err := resourceservices.ProvideTeamPermissions(routeRegister, db, acmock, database.ProvideService(db)) + teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(routeRegister, db, acmock, database.ProvideService(db)) require.NoError(t, err) - hs.TeamPermissionsService = teamPermissionService + hs.teamPermissionsService = teamPermissionService } else { ac := ossaccesscontrol.ProvideService(hs.Features, &usagestats.UsageStatsMock{T: t}, database.ProvideService(db), routing.NewRouteRegister()) @@ -379,9 +386,9 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont require.NoError(t, err) err = ac.RegisterFixedRoles() require.NoError(t, err) - teamPermissionService, err := resourceservices.ProvideTeamPermissions(routeRegister, db, ac, database.ProvideService(db)) + teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(routeRegister, db, ac, database.ProvideService(db)) require.NoError(t, err) - hs.TeamPermissionsService = teamPermissionService + hs.teamPermissionsService = teamPermissionService } // Instantiate a new Server @@ -402,12 +409,13 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont hs.RouteRegister.Register(m.Router) return accessControlScenarioContext{ - server: m, - initCtx: initCtx, - hs: hs, - acmock: acmock, - db: db, - cfg: cfg, + server: m, + initCtx: initCtx, + hs: hs, + acmock: acmock, + db: db, + cfg: cfg, + dashboardsStore: dashboardsStore, } } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 45a5b577ba8..8568a8b4615 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -148,8 +148,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { meta.FolderUrl = query.Result.GetUrl() } - svc := dashboards.NewProvisioningService(hs.SQLStore) - provisioningData, err := svc.GetProvisionedDashboardDataByDashboardID(dash.Id) + provisioningData, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardID(dash.Id) if err != nil { return response.Error(500, "Error while checking if dashboard is provisioned", err) } @@ -233,8 +232,8 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { if err != nil { hs.log.Error("Failed to disconnect library elements", "dashboard", dash.Id, "user", c.SignedInUser.UserId, "error", err) } - svc := dashboards.NewService(hs.SQLStore) - err = svc.DeleteDashboard(c.Req.Context(), dash.Id, c.OrgId) + + err = hs.dashboardService.DeleteDashboard(c.Req.Context(), dash.Id, c.OrgId) if err != nil { var dashboardErr models.DashboardErr if ok := errors.As(err, &dashboardErr); ok { @@ -271,8 +270,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa cmd.OrgId = c.OrgId cmd.UserId = c.UserId if cmd.FolderUid != "" { - folders := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - folder, err := folders.GetFolderByUID(ctx, cmd.FolderUid) + folder, err := hs.folderService.GetFolderByUID(ctx, c.SignedInUser, c.OrgId, cmd.FolderUid) if err != nil { if errors.Is(err, models.ErrFolderNotFound) { return response.Error(400, "Folder not found", err) @@ -294,18 +292,17 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa } } - svc := dashboards.NewProvisioningService(hs.SQLStore) var provisioningData *models.DashboardProvisioning if dash.Id != 0 { - data, err := svc.GetProvisionedDashboardDataByDashboardID(dash.Id) + data, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardID(dash.Id) if err != nil { return response.Error(500, "Error while checking if dashboard is provisioned using ID", err) } provisioningData = data } else if dash.Uid != "" { - data, err := svc.GetProvisionedDashboardDataByDashboardUID(dash.OrgId, dash.Uid) - if err != nil && (!errors.Is(err, models.ErrProvisionedDashboardNotFound) && !errors.Is(err, models.ErrDashboardNotFound)) { - return response.Error(500, "Error while checking if dashboard is provisioned using UID", err) + data, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardUID(dash.OrgId, dash.Uid) + if err != nil && !errors.Is(err, models.ErrProvisionedDashboardNotFound) && !errors.Is(err, models.ErrDashboardNotFound) { + return response.Error(500, "Error while checking if dashboard is provisioned", err) } provisioningData = data } @@ -329,8 +326,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa Overwrite: cmd.Overwrite, } - dashSvc := dashboards.NewService(hs.SQLStore) - dashboard, err := dashSvc.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate) + dashboard, err := hs.dashboardService.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate) if hs.Live != nil { // Tell everyone listening that the dashboard changed @@ -362,7 +358,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa if hs.Cfg.EditorsCanAdmin && newDashboard { inFolder := cmd.FolderId > 0 - err := dashSvc.MakeUserAdmin(ctx, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) + err := hs.dashboardService.MakeUserAdmin(ctx, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) if err != nil { hs.log.Error("Could not make user admin", "dashboard", dashboard.Title, "user", c.SignedInUser.UserId, "error", err) } diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 275acc82d13..da6833dc395 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -112,7 +112,7 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response. return response.Error(403, "Cannot remove own admin permission for a folder", nil) } - if err := updateDashboardACL(c.Req.Context(), hs.SQLStore, dashID, items); err != nil { + if err := hs.dashboardService.UpdateDashboardACL(c.Req.Context(), dashID, items); err != nil { if errors.Is(err, models.ErrDashboardAclInfoMissing) || errors.Is(err, models.ErrDashboardPermissionDashboardEmpty) { return response.Error(409, err.Error(), err) diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index f5dd0f490b0..a64ef0b8da3 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -1,59 +1,38 @@ package api import ( - "context" "encoding/json" "fmt" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func TestDashboardPermissionAPIEndpoint(t *testing.T) { t.Run("Dashboard permissions test", func(t *testing.T) { settings := setting.NewCfg() + dashboardStore := &database.FakeDashboardStore{} + defer dashboardStore.AssertExpectations(t) + mockSQLStore := mockstore.NewSQLStoreMock() + hs := &HTTPServer{ - Cfg: settings, - SQLStore: mockSQLStore, + Cfg: settings, + dashboardService: dashboardservice.ProvideDashboardService(dashboardStore), + SQLStore: mockSQLStore, } - t.Run("Given dashboard not exists", func(t *testing.T) { - mockSQLStore.ExpectedError = models.ErrDashboardNotFound - loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions", - "/api/dashboards/id/:dashboardId/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { - callGetDashboardPermissions(sc, hs) - assert.Equal(t, 404, sc.resp.Code) - }, mockSQLStore) - - cmd := dtos.UpdateDashboardAclCommand{ - Items: []dtos.DashboardAclUpdateItem{ - {UserID: 1000, Permission: models.PERMISSION_ADMIN}, - }, - } - - updateDashboardPermissionScenario(t, updatePermissionContext{ - desc: "When calling POST on", - url: "/api/dashboards/id/1/permissions", - routePattern: "/api/dashboards/id/:dashboardId/permissions", - cmd: cmd, - fn: func(sc *scenarioContext) { - callUpdateDashboardPermissions(t, sc) - assert.Equal(t, 404, sc.resp.Code) - }, - }, hs) - }) - t.Run("Given user has no admin permissions", func(t *testing.T) { origNewGuardian := guardian.New t.Cleanup(func() { @@ -63,7 +42,6 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) getDashboardQueryResult := models.NewDashboard("Dash") - mockSQLStore := mockstore.NewSQLStoreMock() mockSQLStore.ExpectedDashboard = getDashboardQueryResult mockSQLStore.ExpectedError = nil @@ -80,6 +58,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { }, } + dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() updateDashboardPermissionScenario(t, updatePermissionContext{ desc: "When calling POST on", url: "/api/dashboards/id/1/permissions", @@ -324,26 +303,20 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { } assert.Len(t, cmd.Items, 3) + var numOfItems []*models.DashboardAcl + dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + items := args.Get(2).([]*models.DashboardAcl) + numOfItems = items + }).Return(nil).Once() updateDashboardPermissionScenario(t, updatePermissionContext{ desc: "When calling POST on", url: "/api/dashboards/id/1/permissions", routePattern: "/api/dashboards/id/:dashboardId/permissions", cmd: cmd, fn: func(sc *scenarioContext) { - // TODO: Replace this fake with a fake SQLStore instead (once we can use an interface in its stead) - origUpdateDashboardACL := updateDashboardACL - t.Cleanup(func() { - updateDashboardACL = origUpdateDashboardACL - }) - var gotItems []*models.DashboardAcl - updateDashboardACL = func(_ context.Context, _ dashboards.Store, folderID int64, items []*models.DashboardAcl) error { - gotItems = items - return nil - } - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) - assert.Len(t, gotItems, 4) + assert.Len(t, numOfItems, 4) }, }, hs) }) @@ -357,15 +330,6 @@ func callGetDashboardPermissions(sc *scenarioContext, hs *HTTPServer) { func callUpdateDashboardPermissions(t *testing.T, sc *scenarioContext) { t.Helper() - - origUpdateDashboardACL := updateDashboardACL - t.Cleanup(func() { - updateDashboardACL = origUpdateDashboardACL - }) - updateDashboardACL = func(_ context.Context, _ dashboards.Store, dashID int64, items []*models.DashboardAcl) error { - return nil - } - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 5179dc0094f..1d5c45cc5f7 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -133,7 +133,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res metrics.MApiDashboardSnapshotCreate.Inc() } - if err := hs.SQLStore.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil { + if err := hs.DashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil { c.JsonApiErr(500, "Failed to create snapshot", err) return nil } @@ -157,7 +157,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon query := &models.GetDashboardSnapshotQuery{Key: key} - err := hs.SQLStore.GetDashboardSnapshot(query) + err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Error(500, "Failed to get dashboard snapshot", err) } @@ -224,8 +224,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r } query := &models.GetDashboardSnapshotQuery{DeleteKey: key} - - err := hs.SQLStore.GetDashboardSnapshot(query) + err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Error(500, "Failed to get dashboard snapshot", err) } @@ -239,7 +238,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} - if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { + if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { return response.Error(500, "Failed to delete dashboard snapshot", err) } @@ -258,7 +257,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res query := &models.GetDashboardSnapshotQuery{Key: key} - err := hs.SQLStore.GetDashboardSnapshot(query) + err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Error(500, "Failed to get dashboard snapshot", err) } @@ -287,7 +286,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} - if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { + if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { return response.Error(500, "Failed to delete dashboard snapshot", err) } @@ -313,7 +312,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re SignedInUser: c.SignedInUser, } - err := hs.SQLStore.SearchDashboardSnapshots(&searchQuery) + err := hs.DashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery) if err != nil { return response.Error(500, "Search failed", err) } diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 5bf2ae90c4b..434714ba131 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -12,6 +12,7 @@ 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/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,7 +34,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { editorRole := models.ROLE_EDITOR sqlmock := mockstore.NewSQLStoreMock() aclMockResp := []*models.DashboardAclInfoDTO{} - hs := &HTTPServer{SQLStore: sqlmock} + hs := &HTTPServer{DashboardsnapshotsService: &dashboardsnapshots.Service{SQLStore: sqlmock}} setUpSnapshotTest := func(t *testing.T) *models.DashboardSnapshot { t.Helper() diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 3c4f21ec618..01f87d54b0a 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -7,7 +7,6 @@ import ( "fmt" "io/ioutil" "net/http" - "path/filepath" "testing" "github.com/grafana/grafana/pkg/api/dtos" @@ -15,11 +14,12 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" - dboards "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + service "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/live" @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -145,7 +146,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { setUp() sc.sqlStore = mockSQLStore - dash := getDashboardShouldReturn200(sc) + dash := getDashboardShouldReturn200(t, sc) assert.False(t, dash.Meta.CanEdit) assert.False(t, dash.Meta.CanSave) @@ -177,7 +178,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { setUp() sc.sqlStore = mockSQLStore - dash := getDashboardShouldReturn200(sc) + dash := getDashboardShouldReturn200(t, sc) assert.True(t, dash.Meta.CanEdit) assert.True(t, dash.Meta.CanSave) @@ -212,11 +213,13 @@ func TestDashboardAPIEndpoint(t *testing.T) { mockSQLStore := mockstore.NewSQLStoreMock() mockSQLStore.ExpectedDashboard = fakeDash + dashboardStore := database.ProvideDashboardStore(sqlstore.InitTestDB(t)) hs := &HTTPServer{ Cfg: setting.NewCfg(), Live: newTestLive(t), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + dashboardService: service.ProvideDashboardService(dashboardStore), SQLStore: mockSQLStore, } hs.SQLStore = mockSQLStore @@ -345,7 +348,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { setUpInner() sc.sqlStore = mockSQLStore - dash := getDashboardShouldReturn200(sc) + dash := getDashboardShouldReturn200(t, sc) assert.True(t, dash.Meta.CanEdit) assert.True(t, dash.Meta.CanSave) @@ -412,7 +415,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { require.True(t, setting.ViewersCanEdit) sc.sqlStore = mockSQLStore - dash := getDashboardShouldReturn200(sc) + dash := getDashboardShouldReturn200(t, sc) assert.True(t, dash.Meta.CanEdit) assert.False(t, dash.Meta.CanSave) @@ -445,7 +448,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { setUpInner() sc.sqlStore = mockSQLStore - dash := getDashboardShouldReturn200(sc) + dash := getDashboardShouldReturn200(t, sc) assert.True(t, dash.Meta.CanEdit) assert.True(t, dash.Meta.CanSave) @@ -493,7 +496,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { setUpInner() sc.sqlStore = mockSQLStore - dash := getDashboardShouldReturn200(sc) + dash := getDashboardShouldReturn200(t, sc) assert.False(t, dash.Meta.CanEdit) assert.False(t, dash.Meta.CanSave) @@ -535,6 +538,8 @@ func TestDashboardAPIEndpoint(t *testing.T) { }) t.Run("Post dashboard response tests", func(t *testing.T) { + dashboardStore := &database.FakeDashboardStore{} + defer dashboardStore.AssertExpectations(t) // This tests that a valid request returns correct response t.Run("Given a correct request for creating a dashboard", func(t *testing.T) { const folderID int64 = 3 @@ -562,7 +567,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { }, } - postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, nil, cmd, func(sc *scenarioContext) { + postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, mock, nil, func(sc *scenarioContext) { callPostDashboardShouldReturnSuccess(sc) dto := mock.SavedDashboards[0] @@ -612,7 +617,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { GetFolderByUIDResult: &models.Folder{Id: 1, Uid: "folderUID", Title: "Folder"}, } - postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, mockFolder, cmd, func(sc *scenarioContext) { + postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, mock, mockFolder, func(sc *scenarioContext) { callPostDashboardShouldReturnSuccess(sc) dto := mock.SavedDashboards[0] @@ -661,7 +666,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { GetFolderByUIDError: errors.New("Error while searching Folder ID"), } - postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, mockFolder, cmd, func(sc *scenarioContext) { + postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, mock, mockFolder, func(sc *scenarioContext) { callPostDashboard(sc) assert.Equal(t, 500, sc.resp.Code) }) @@ -706,7 +711,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { } postDashboardScenario(t, fmt.Sprintf("Expect '%s' error when calling POST on", tc.SaveError.Error()), - "/api/dashboards", "/api/dashboards", mock, nil, cmd, func(sc *scenarioContext) { + "/api/dashboards", "/api/dashboards", cmd, mock, nil, func(sc *scenarioContext) { callPostDashboard(sc) assert.Equal(t, tc.ExpectedStatusCode, sc.resp.Code) }) @@ -852,14 +857,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { t.Run("Given provisioned dashboard", func(t *testing.T) { setUp := func() { - origGetProvisionedData := dashboards.GetProvisionedData - t.Cleanup(func() { - dashboards.GetProvisionedData = origGetProvisionedData - }) - dashboards.GetProvisionedData = func(dboards.Store, int64) (*models.DashboardProvisioning, error) { - return &models.DashboardProvisioning{ExternalId: "/tmp/grafana/dashboards/test/dashboard1.json"}, nil - } - bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error { query.Result = []*models.DashboardAclInfoDTO{ {OrgId: testOrgID, DashboardId: 1, UserId: testUserID, Permission: models.PERMISSION_EDIT}, @@ -867,6 +864,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { return nil }) } + mockSQLStore := mockstore.NewSQLStoreMock() dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "editable": true, "style": "dark"}`)) require.NoError(t, err) @@ -874,37 +872,39 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() - dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "editable": true, "style": "dark"}`)) - require.NoError(t, err) - mockSQLStore.ExpectedDashboard = &models.Dashboard{Id: 1, Data: dataValue} - sc.sqlStore = mockSQLStore - mock := provisioning.NewProvisioningServiceMock(context.Background()) - mock.GetDashboardProvisionerResolvedPathFunc = func(name string) string { + fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background()) + fakeProvisioningService.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "/tmp/grafana/dashboards" } - dash := getDashboardShouldReturn200WithConfig(sc, mock) + dashboardStore := &database.FakeDashboardStore{} + defer dashboardStore.AssertExpectations(t) - assert.Equal(t, filepath.Join("test", "dashboard1.json"), dash.Meta.ProvisionedExternalId) + dashboardStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{ExternalId: "/dashboard1.json"}, nil).Once() + + dash := getDashboardShouldReturn200WithConfig(t, sc, fakeProvisioningService, dashboardStore) + + assert.Equal(t, "../../../dashboard1.json", dash.Meta.ProvisionedExternalId, mockSQLStore) }, mockSQLStore) loggedInUserScenarioWithRole(t, "When allowUiUpdates is true and calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() - - mock := provisioning.NewProvisioningServiceMock(context.Background()) - mock.GetDashboardProvisionerResolvedPathFunc = func(name string) string { + fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background()) + fakeProvisioningService.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "/tmp/grafana/dashboards" } - mock.GetAllowUIUpdatesFromConfigFunc = func(name string) bool { + + fakeProvisioningService.GetAllowUIUpdatesFromConfigFunc = func(name string) bool { return true } hs := &HTTPServer{ - Cfg: setting.NewCfg(), - ProvisioningService: mock, - LibraryPanelService: &mockLibraryPanelService{}, - LibraryElementService: &mockLibraryElementService{}, - SQLStore: mockSQLStore, + Cfg: setting.NewCfg(), + ProvisioningService: fakeProvisioningService, + LibraryPanelService: &mockLibraryPanelService{}, + LibraryElementService: &mockLibraryElementService{}, + dashboardProvisioningService: mockDashboardProvisioningService{}, + SQLStore: mockSQLStore, } hs.callGetDashboard(sc) @@ -919,21 +919,28 @@ func TestDashboardAPIEndpoint(t *testing.T) { }) } -func getDashboardShouldReturn200WithConfig(sc *scenarioContext, provisioningService provisioning.ProvisioningService) dtos. - DashboardFullWithMeta { +func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, provisioningService provisioning.ProvisioningService, dashboardStore dashboards.Store) dtos.DashboardFullWithMeta { + t.Helper() + if provisioningService == nil { provisioningService = provisioning.NewProvisioningServiceMock(context.Background()) } + if dashboardStore == nil { + sql := sqlstore.InitTestDB(t) + dashboardStore = database.ProvideDashboardStore(sql) + } + libraryPanelsService := mockLibraryPanelService{} libraryElementsService := mockLibraryElementService{} hs := &HTTPServer{ - Cfg: setting.NewCfg(), - LibraryPanelService: &libraryPanelsService, - LibraryElementService: &libraryElementsService, - ProvisioningService: provisioningService, - SQLStore: sc.sqlStore, + Cfg: setting.NewCfg(), + LibraryPanelService: &libraryPanelsService, + LibraryElementService: &libraryElementsService, + ProvisioningService: provisioningService, + dashboardProvisioningService: service.ProvideDashboardService(dashboardStore), + SQLStore: sc.sqlStore, } hs.callGetDashboard(sc) @@ -947,8 +954,8 @@ func getDashboardShouldReturn200WithConfig(sc *scenarioContext, provisioningServ return dash } -func getDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta { - return getDashboardShouldReturn200WithConfig(sc, nil) +func getDashboardShouldReturn200(t *testing.T, sc *scenarioContext) dtos.DashboardFullWithMeta { + return getDashboardShouldReturn200WithConfig(t, sc, nil, nil) } func (hs *HTTPServer) callGetDashboard(sc *scenarioContext) { @@ -976,17 +983,13 @@ func (hs *HTTPServer) callGetDashboardVersions(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } -func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T, sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) { +func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T, + sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) { bus.AddHandler("test", func(ctx context.Context, cmd *models.DeleteDashboardCommand) error { return nil }) - origNewDashboardService := dashboards.NewService - t.Cleanup(func() { - dashboards.NewService = origNewDashboardService - }) - dashboards.MockDashboardService(mockDashboard) - + hs.dashboardService = mockDashboard sc.handlerFunc = hs.DeleteDashboardByUID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } @@ -1005,9 +1008,7 @@ func callPostDashboardShouldReturnSuccess(sc *scenarioContext) { assert.Equal(sc.t, 200, sc.resp.Code) } -func postDashboardScenario(t *testing.T, desc string, url string, routePattern string, - mock *dashboards.FakeDashboardService, mockFolder *fakeFolderService, cmd models.SaveDashboardCommand, - fn scenarioFunc) { +func postDashboardScenario(t *testing.T, desc string, url string, routePattern string, cmd models.SaveDashboardCommand, dashboardService dashboards.DashboardService, folderService dashboards.FolderService, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) @@ -1023,6 +1024,8 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s pluginStore: &fakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + dashboardService: dashboardService, + folderService: folderService, } sc := setupScenarioContext(t, url) @@ -1035,20 +1038,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s return hs.PostDashboard(c) }) - origNewDashboardService := dashboards.NewService - origProvisioningService := dashboards.NewProvisioningService - origNewFolderService := dashboards.NewFolderService - t.Cleanup(func() { - dashboards.NewService = origNewDashboardService - dashboards.NewProvisioningService = origProvisioningService - dashboards.NewFolderService = origNewFolderService - }) - dashboards.MockDashboardService(mock) - dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService { - return mockDashboardProvisioningService{} - } - mockFolderService(mockFolder) - sc.m.Post(routePattern, sc.defaultHandler) fn(sc) @@ -1091,9 +1080,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string }) } -func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string, - mock *dashboards.FakeDashboardService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, - sqlStore sqlstore.Store) { +func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, sqlStore sqlstore.Store) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { defer bus.ClearBusHandlers() @@ -1107,6 +1094,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout QuotaService: "a.QuotaService{Cfg: cfg}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, + dashboardService: mock, SQLStore: sqlStore, } @@ -1125,17 +1113,6 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout return hs.RestoreDashboardVersion(c) }) - origProvisioningService := dashboards.NewProvisioningService - origNewDashboardService := dashboards.NewService - t.Cleanup(func() { - dashboards.NewService = origNewDashboardService - dashboards.NewProvisioningService = origProvisioningService - }) - dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService { - return mockDashboardProvisioningService{} - } - dashboards.MockDashboardService(mock) - sc.m.Post(routePattern, sc.defaultHandler) fn(sc) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 138635024c6..9af7d1c247b 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/adapters" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -71,23 +70,6 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { return response.JSON(200, &result) } -func (hs *HTTPServer) getDataSourceAccessControlMetadata(c *models.ReqContext, dsID int64) (accesscontrol.Metadata, error) { - if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, - accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - return nil, err - } - - key := fmt.Sprintf("%d", dsID) - dsIDs := map[string]bool{key: true} - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "datasources", dsIDs)[key], nil -} - // GET /api/datasources/:id func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) @@ -115,12 +97,9 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response } dto := convertModelToDtos(filtered[0]) + // Add accesscontrol metadata - metadata, err := hs.getDataSourceAccessControlMetadata(c, dto.Id) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to query metadata", err) - } - dto.AccessControl = metadata + dto.AccessControl = hs.getAccessControlMetadata(c, "datasources", dto.Id) return response.JSON(200, &dto) } @@ -179,11 +158,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response dto := convertModelToDtos(filtered[0]) // Add accesscontrol metadata - metadata, err := hs.getDataSourceAccessControlMetadata(c, dto.Id) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to query metadata", err) - } - dto.AccessControl = metadata + dto.AccessControl = hs.getAccessControlMetadata(c, "datasources", dto.Id) return response.JSON(200, &dto) } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 5afb1e62064..64e2d5b531f 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -71,6 +71,7 @@ type NavLink struct { HideFromTabs bool `json:"hideFromTabs,omitempty"` Children []*NavLink `json:"children,omitempty"` HighlightText string `json:"highlightText,omitempty"` + HighlightID string `json:"highlightId,omitempty"` } // NavIDCfg is the id for org configuration navigation node diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 3fcf762ef89..1c08a0403da 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/util" @@ -19,8 +18,7 @@ import ( ) func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - folders, err := s.GetFolders(c.Req.Context(), c.QueryInt64("limit"), c.QueryInt64("page")) + folders, err := hs.folderService.GetFolders(c.Req.Context(), c.SignedInUser, c.OrgId, c.QueryInt64("limit"), c.QueryInt64("page")) if err != nil { return apierrors.ToFolderErrorResponse(err) @@ -40,8 +38,7 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { } func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - folder, err := s.GetFolderByUID(c.Req.Context(), web.Params(c.Req)[":uid"]) + folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"]) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -51,14 +48,11 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { } func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } - - folder, err := s.GetFolderByID(c.Req.Context(), id) + folder, err := hs.folderService.GetFolderByID(c.Req.Context(), c.SignedInUser, c.OrgId, id) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -72,14 +66,13 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - folder, err := s.CreateFolder(c.Req.Context(), cmd.Title, cmd.Uid) + folder, err := hs.folderService.CreateFolder(c.Req.Context(), c.SignedInUser, c.OrgId, cmd.Title, cmd.Uid) if err != nil { return apierrors.ToFolderErrorResponse(err) } if hs.Cfg.EditorsCanAdmin { - if err := s.MakeUserAdmin(c.Req.Context(), c.OrgId, c.SignedInUser.UserId, folder.Id, true); err != nil { + if err := hs.folderService.MakeUserAdmin(c.Req.Context(), c.OrgId, c.SignedInUser.UserId, folder.Id, true); err != nil { hs.log.Error("Could not make user admin", "folder", folder.Title, "user", c.SignedInUser.UserId, "error", err) } @@ -94,8 +87,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - err := s.UpdateFolder(c.Req.Context(), web.Params(c.Req)[":uid"], &cmd) + err := hs.folderService.UpdateFolder(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"], &cmd) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -105,7 +97,6 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { } func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"]) if err != nil { if errors.Is(err, libraryelements.ErrFolderHasConnectedLibraryElements) { @@ -114,7 +105,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // return apierrors.ToFolderErrorResponse(err) } - f, err := s.DeleteFolder(c.Req.Context(), web.Params(c.Req)[":uid"], c.QueryBool("forceDeleteRules")) + f, err := hs.folderService.DeleteFolder(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"], c.QueryBool("forceDeleteRules")) if err != nil { return apierrors.ToFolderErrorResponse(err) } diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 3ffd756b3f0..ca03d2497e8 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -9,15 +9,13 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Response { - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - folder, err := s.GetFolderByUID(c.Req.Context(), web.Params(c.Req)[":uid"]) + folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"]) if err != nil { return apierrors.ToFolderErrorResponse(err) @@ -68,8 +66,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return response.Error(400, err.Error(), err) } - s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore) - folder, err := s.GetFolderByUID(c.Req.Context(), web.Params(c.Req)[":uid"]) + folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"]) if err != nil { return apierrors.ToFolderErrorResponse(err) } @@ -117,7 +114,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return response.Error(403, "Cannot remove own admin permission for a folder", nil) } - if err := updateDashboardACL(c.Req.Context(), hs.SQLStore, folder.Id, items); err != nil { + if err := hs.dashboardService.UpdateDashboardACL(c.Req.Context(), folder.Id, items); err != nil { if errors.Is(err, models.ErrDashboardAclInfoMissing) { err = models.ErrFolderAclInfoMissing } diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index 395e4a58f2a..46df1f74192 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "fmt" "testing" @@ -13,28 +12,28 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" - dashboardifaces "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + service "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/mock" ) func TestFolderPermissionAPIEndpoint(t *testing.T) { settings := setting.NewCfg() - hs := &HTTPServer{Cfg: settings} + folderService := &dashboards.FakeFolderService{} + defer folderService.AssertExpectations(t) + + dashboardStore := &database.FakeDashboardStore{} + defer dashboardStore.AssertExpectations(t) + + hs := &HTTPServer{Cfg: settings, folderService: folderService, dashboardService: service.ProvideDashboardService(dashboardStore)} t.Run("Given folder not exists", func(t *testing.T) { - mock := &fakeFolderService{ - GetFolderByUIDError: models.ErrFolderNotFound, - } - - origNewFolderService := dashboards.NewFolderService - t.Cleanup(func() { - dashboards.NewFolderService = origNewFolderService - }) - mockFolderService(mock) + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrFolderNotFound).Twice() mockSQLStore := mockstore.NewSQLStoreMock() loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) @@ -61,24 +60,14 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { t.Run("Given user has no admin permissions", func(t *testing.T) { origNewGuardian := guardian.New - origNewFolderService := dashboards.NewFolderService t.Cleanup(func() { guardian.New = origNewGuardian - dashboards.NewFolderService = origNewFolderService }) guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false}) - - mock := &fakeFolderService{ - GetFolderByUIDResult: &models.Folder{ - Id: 1, - Uid: "uid", - Title: "Folder", - }, - } - - mockFolderService(mock) + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrFolderAccessDenied).Twice() mockSQLStore := mockstore.NewSQLStoreMock() + loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) assert.Equal(t, 403, sc.resp.Code) @@ -104,10 +93,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { t.Run("Given user has admin permissions and permissions to update", func(t *testing.T) { origNewGuardian := guardian.New - origNewFolderService := dashboards.NewFolderService t.Cleanup(func() { guardian.New = origNewGuardian - dashboards.NewFolderService = origNewFolderService }) guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ @@ -122,16 +109,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { }, }) - mock := &fakeFolderService{ - GetFolderByUIDResult: &models.Folder{ - Id: 1, - Uid: "uid", - Title: "Folder", - }, - } - - mockFolderService(mock) + folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Twice() + dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() mockSQLStore := mockstore.NewSQLStoreMock() + loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) { callGetFolderPermissions(sc, hs) assert.Equal(t, 200, sc.resp.Code) @@ -175,10 +157,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { t.Run("When trying to update permissions with duplicate permissions", func(t *testing.T) { origNewGuardian := guardian.New - origNewFolderService := dashboards.NewFolderService t.Cleanup(func() { guardian.New = origNewGuardian - dashboards.NewFolderService = origNewFolderService }) guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ @@ -187,15 +167,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists, }) - mock := &fakeFolderService{ - GetFolderByUIDResult: &models.Folder{ - Id: 1, - Uid: "uid", - Title: "Folder", - }, - } - - mockFolderService(mock) + folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Once() cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -249,10 +222,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { t.Run("When trying to override inherited permissions with lower precedence", func(t *testing.T) { origNewGuardian := guardian.New - origNewFolderService := dashboards.NewFolderService t.Cleanup(func() { guardian.New = origNewGuardian - dashboards.NewFolderService = origNewFolderService }) guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ @@ -261,15 +232,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride}, ) - mock := &fakeFolderService{ - GetFolderByUIDResult: &models.Folder{ - Id: 1, - Uid: "uid", - Title: "Folder", - }, - } - - mockFolderService(mock) + folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Once() cmd := dtos.UpdateDashboardAclCommand{ Items: []dtos.DashboardAclUpdateItem{ @@ -291,14 +255,12 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { t.Run("Getting and updating folder permissions with hidden users", func(t *testing.T) { origNewGuardian := guardian.New - origNewFolderService := dashboards.NewFolderService settings.HiddenUsers = map[string]struct{}{ "hiddenUser": {}, testUserLogin: {}, } t.Cleanup(func() { guardian.New = origNewGuardian - dashboards.NewFolderService = origNewFolderService settings.HiddenUsers = make(map[string]struct{}) }) @@ -315,15 +277,13 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { }, }) - mock := &fakeFolderService{ - GetFolderByUIDResult: &models.Folder{ - Id: 1, - Uid: "uid", - Title: "Folder", - }, - } + var gotItems []*models.DashboardAcl - mockFolderService(mock) + folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Twice() + dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + gotItems = args.Get(2).([]*models.DashboardAcl) + }).Return(nil).Once() var resp []*models.DashboardAclInfoDTO mockSQLStore := mockstore.NewSQLStoreMock() @@ -360,16 +320,6 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { routePattern: "/api/folders/:uid/permissions", cmd: cmd, fn: func(sc *scenarioContext) { - origUpdateDashboardACL := updateDashboardACL - t.Cleanup(func() { - updateDashboardACL = origUpdateDashboardACL - }) - var gotItems []*models.DashboardAcl - updateDashboardACL = func(_ context.Context, _ dashboardifaces.Store, _ int64, items []*models.DashboardAcl) error { - gotItems = items - return nil - } - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) assert.Len(t, gotItems, 4) @@ -385,21 +335,12 @@ func callGetFolderPermissions(sc *scenarioContext, hs *HTTPServer) { func callUpdateFolderPermissions(t *testing.T, sc *scenarioContext) { t.Helper() - - origUpdateDashboardACL := updateDashboardACL - t.Cleanup(func() { - updateDashboardACL = origUpdateDashboardACL - }) - updateDashboardACL = func(_ context.Context, _ dashboardifaces.Store, dashID int64, items []*models.DashboardAcl) error { - return nil - } - sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } func updateFolderPermissionScenario(t *testing.T, ctx updatePermissionContext, hs *HTTPServer) { t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) { - defer bus.ClearBusHandlers() + t.Cleanup(bus.ClearBusHandlers) sc := setupScenarioContext(t, ctx.url) diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 08ae3853a3b..cf10dc46952 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -10,26 +10,28 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" - dboards "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) func TestFoldersAPIEndpoint(t *testing.T) { + folderService := &dashboards.FakeFolderService{} + defer folderService.AssertExpectations(t) + t.Run("Given a correct request for creating a folder", func(t *testing.T) { cmd := models.CreateFolderCommand{ Uid: "uid", Title: "Folder", } - mock := &fakeFolderService{ - CreateFolderResult: &models.Folder{Id: 1, Uid: "uid", Title: "Folder"}, - } + folderResult := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"} + folderService.On("CreateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResult, nil).Once() - createFolderScenario(t, "When calling POST on", "/api/folders", "/api/folders", mock, cmd, + createFolderScenario(t, "When calling POST on", "/api/folders", "/api/folders", folderService, cmd, func(sc *scenarioContext) { callCreateFolder(sc) @@ -64,12 +66,10 @@ func TestFoldersAPIEndpoint(t *testing.T) { } for _, tc := range testCases { - mock := &fakeFolderService{ - CreateFolderError: tc.Error, - } + folderService.On("CreateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, tc.Error).Once() createFolderScenario(t, fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()), - "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) { + "/api/folders", "/api/folders", folderService, cmd, func(sc *scenarioContext) { callCreateFolder(sc) assert.Equalf(t, tc.ExpectedStatusCode, sc.resp.Code, "Wrong status code for error %s", tc.Error) }) @@ -81,11 +81,12 @@ func TestFoldersAPIEndpoint(t *testing.T) { Title: "Folder upd", } - mock := &fakeFolderService{ - UpdateFolderResult: &models.Folder{Id: 1, Uid: "uid", Title: "Folder upd"}, - } + folderService.On("UpdateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + cmd := args.Get(4).(*models.UpdateFolderCommand) + cmd.Result = &models.Folder{Id: 1, Uid: "uid", Title: "Folder upd"} + }).Return(nil).Once() - updateFolderScenario(t, "When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd, + updateFolderScenario(t, "When calling PUT on", "/api/folders/uid", "/api/folders/:uid", folderService, cmd, func(sc *scenarioContext) { callUpdateFolder(sc) @@ -119,12 +120,9 @@ func TestFoldersAPIEndpoint(t *testing.T) { } for _, tc := range testCases { - mock := &fakeFolderService{ - UpdateFolderError: tc.Error, - } - + folderService.On("UpdateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.Error).Once() updateFolderScenario(t, fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()), - "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) { + "/api/folders/uid", "/api/folders/:uid", folderService, cmd, func(sc *scenarioContext) { callUpdateFolder(sc) assert.Equalf(t, tc.ExpectedStatusCode, sc.resp.Code, "Wrong status code for %s", tc.Error) }) @@ -136,14 +134,15 @@ func callCreateFolder(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } -func createFolderScenario(t *testing.T, desc string, url string, routePattern string, mock *fakeFolderService, +func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService, cmd models.CreateFolderCommand, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { t.Cleanup(bus.ClearBusHandlers) hs := HTTPServer{ - Bus: bus.GetBus(), - Cfg: setting.NewCfg(), + Bus: bus.GetBus(), + Cfg: setting.NewCfg(), + folderService: folderService, } sc := setupScenarioContext(t, url) @@ -156,15 +155,8 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st return hs.CreateFolder(c) }) - origNewFolderService := dashboards.NewFolderService - mockFolderService(mock) - sc.m.Post(routePattern, sc.defaultHandler) - defer func() { - dashboards.NewFolderService = origNewFolderService - }() - fn(sc) }) } @@ -173,13 +165,14 @@ func callUpdateFolder(sc *scenarioContext) { sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() } -func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, mock *fakeFolderService, +func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService, cmd models.UpdateFolderCommand, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { defer bus.ClearBusHandlers() hs := HTTPServer{ - Cfg: setting.NewCfg(), + Cfg: setting.NewCfg(), + folderService: folderService, } sc := setupScenarioContext(t, url) @@ -192,12 +185,6 @@ func updateFolderScenario(t *testing.T, desc string, url string, routePattern st return hs.UpdateFolder(c) }) - origNewFolderService := dashboards.NewFolderService - t.Cleanup(func() { - dashboards.NewFolderService = origNewFolderService - }) - mockFolderService(mock) - sc.m.Put(routePattern, sc.defaultHandler) fn(sc) @@ -222,35 +209,28 @@ type fakeFolderService struct { DeletedFolderUids []string } -func (s *fakeFolderService) GetFolders(ctx context.Context, limit int64, page int64) ([]*models.Folder, error) { +func (s *fakeFolderService) GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) { return s.GetFoldersResult, s.GetFoldersError } -func (s *fakeFolderService) GetFolderByID(ctx context.Context, id int64) (*models.Folder, error) { +func (s *fakeFolderService) GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) { return s.GetFolderByIDResult, s.GetFolderByIDError } -func (s *fakeFolderService) GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error) { +func (s *fakeFolderService) GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) { return s.GetFolderByUIDResult, s.GetFolderByUIDError } -func (s *fakeFolderService) CreateFolder(ctx context.Context, title, uid string) (*models.Folder, error) { +func (s *fakeFolderService) CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) { return s.CreateFolderResult, s.CreateFolderError } -func (s *fakeFolderService) UpdateFolder(ctx context.Context, existingUID string, cmd *models.UpdateFolderCommand) error { +func (s *fakeFolderService) UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { cmd.Result = s.UpdateFolderResult return s.UpdateFolderError } -func (s *fakeFolderService) DeleteFolder(ctx context.Context, uid string, forceDeleteRules bool) (*models.Folder, error) { +func (s *fakeFolderService) DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) { s.DeletedFolderUids = append(s.DeletedFolderUids, uid) return s.DeleteFolderResult, s.DeleteFolderError } - -func mockFolderService(mock *fakeFolderService) { - dashboards.NewFolderService = func(orgId int64, user *models.SignedInUser, - dashboardStore dboards.Store) dashboards.FolderService { - return mock - } -} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index f1ea4edaef1..5416cde6b24 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -229,6 +229,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkName": setting.ExternalUserMngLinkName, "viewersCanEdit": setting.ViewersCanEdit, + "angularSupportEnabled": hs.Cfg.AngularSupportEnabled, "editorsCanAdmin": hs.Cfg.EditorsCanAdmin, "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml, "pluginsToPreload": pluginsToPreload, @@ -274,6 +275,10 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled, } + if hs.ThumbService != nil { + jsonObj["dashboardPreviews"] = hs.ThumbService.GetDashboardPreviewsSetupSettings(c) + } + if hs.Cfg.GeomapDefaultBaseLayerConfig != nil { jsonObj["geomapDefaultBaseLayerConfig"] = hs.Cfg.GeomapDefaultBaseLayerConfig } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index af58dbc2744..729bf865099 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -28,11 +28,11 @@ import ( "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/services/accesscontrol" acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption" @@ -128,9 +128,14 @@ type HTTPServer struct { queryDataService *query.Service serviceAccountsService serviceaccounts.Service authInfoService login.AuthInfoService - TeamPermissionsService *resourcepermissions.Service + teamPermissionsService accesscontrol.PermissionsService NotificationService *notifications.NotificationService + dashboardService dashboards.DashboardService + dashboardProvisioningService dashboards.DashboardProvisioningService + folderService dashboards.FolderService DatasourcePermissionsService DatasourcePermissionsService + AlertNotificationService *alerting.AlertNotificationService + DashboardsnapshotsService *dashboardsnapshots.Service } type ServerOptions struct { @@ -157,8 +162,12 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service, ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service, - authInfoService login.AuthInfoService, resourcePermissionServices *resourceservices.ResourceServices, - notificationService *notifications.NotificationService, datasourcePermissionsService DatasourcePermissionsService) (*HTTPServer, error) { + authInfoService login.AuthInfoService, permissionsServices accesscontrol.PermissionsServices, + notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, + dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService, + datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, + dashboardsnapshotsService *dashboardsnapshots.Service, +) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -217,9 +226,14 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi queryDataService: queryDataService, serviceAccountsService: serviceaccountsService, authInfoService: authInfoService, - TeamPermissionsService: resourcePermissionServices.GetTeamService(), NotificationService: notificationService, + dashboardService: dashboardService, + dashboardProvisioningService: dashboardProvisioningService, + folderService: folderService, DatasourcePermissionsService: datasourcePermissionsService, + teamPermissionsService: permissionsServices.GetTeamService(), + AlertNotificationService: alertNotificationService, + DashboardsnapshotsService: dashboardsnapshotsService, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/api/login.go b/pkg/api/login.go index 06688c97297..bad639e6d1d 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -92,7 +92,10 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { enabledOAuths := make(map[string]interface{}) providers := hs.SocialService.GetOAuthInfoProviders() for key, oauth := range providers { - enabledOAuths[key] = map[string]string{"name": oauth.Name} + enabledOAuths[key] = map[string]string{ + "name": oauth.Name, + "icon": oauth.Icon, + } } viewData.Settings["oauth"] = enabledOAuths diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index dfc70933943..cad6b3715f6 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -113,19 +112,6 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo return response.JSON(200, result) } -func (hs *HTTPServer) getUserAccessControlMetadata(c *models.ReqContext, resourceIDs map[string]bool) (map[string]accesscontrol.Metadata, error) { - if hs.AccessControl == nil || hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - return nil, err - } - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "users", resourceIDs), nil -} - // GET /api/orgs/:orgId/users func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) @@ -164,17 +150,11 @@ func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *models.GetO filteredUsers = append(filteredUsers, user) } - accessControlMetadata, errAC := hs.getUserAccessControlMetadata(c, userIDs) - if errAC != nil { - hs.log.Error("Failed to get access control metadata", "error", errAC) - - return filteredUsers, nil - } else if accessControlMetadata == nil { - return filteredUsers, nil - } - - for i := range filteredUsers { - filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserId)] + accessControlMetadata := hs.getMultiAccessControlMetadata(c, "users", userIDs) + if len(accessControlMetadata) > 0 { + for i := range filteredUsers { + filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserId)] + } } return filteredUsers, nil diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index ee5dedb51c7..a098d163932 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -150,7 +150,7 @@ func TestOrgUsersAPIEndpoint_LegacyAccessControl_FolderAdmin(t *testing.T) { "tags": "prod", }), } - folder, err := sc.db.SaveDashboard(cmd) + folder, err := sc.dashboardsStore.SaveDashboard(cmd) require.NoError(t, err) require.NotNil(t, folder) @@ -165,7 +165,7 @@ func TestOrgUsersAPIEndpoint_LegacyAccessControl_FolderAdmin(t *testing.T) { Updated: time.Now(), }, } - err = sc.db.UpdateDashboardACL(context.Background(), folder.Id, acls) + err = sc.dashboardsStore.UpdateDashboardACL(context.Background(), folder.Id, acls) require.NoError(t, err) response := callAPI(sc.server, http.MethodGet, "/api/org/users/lookup", nil, t) diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index bd1808aed6b..fb23163678d 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -129,7 +130,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -142,7 +143,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -154,7 +155,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -165,7 +166,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -179,7 +180,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -188,7 +189,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -198,7 +199,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = models.ROLE_ADMIN - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -289,7 +290,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -305,7 +306,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -322,7 +323,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -344,7 +345,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -370,7 +371,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -394,7 +395,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -422,7 +423,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var pluginRoutes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -445,7 +446,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -509,7 +510,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer, secretsService) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -642,7 +643,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx, ds := setUp(t) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -660,7 +661,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -674,7 +675,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx, ds := setUp(t) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -696,7 +697,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -721,7 +722,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -745,7 +746,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -770,7 +771,7 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { require.NoError(t, err) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) @@ -791,7 +792,7 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -834,7 +835,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) if tc.err == nil { require.NoError(t, err) @@ -861,7 +862,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -985,7 +986,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, test *t require.NoError(t, err) var routes []*plugins.Route - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -1027,7 +1028,7 @@ func Test_PathCheck(t *testing.T) { } ctx, _ := setUp() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) diff --git a/pkg/api/team.go b/pkg/api/team.go index be12efe992a..2b256959e9c 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -2,14 +2,12 @@ package api import ( "errors" - "fmt" "net/http" "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -39,7 +37,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { // the SignedInUser is an empty struct therefore // an additional check whether it is an actual user is required if c.SignedInUser.IsRealUser() { - if err := addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, c.SignedInUser.UserId, c.OrgId, team.Id, models.PERMISSION_ADMIN.String()); err != nil { + if err := addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, c.SignedInUser.UserId, c.OrgId, team.Id, models.PERMISSION_ADMIN.String()); err != nil { c.Logger.Error("Could not add creator to team", "error", err) } } else { @@ -105,20 +103,6 @@ func (hs *HTTPServer) DeleteTeamByID(c *models.ReqContext) response.Response { return response.Success("Team deleted") } -func (hs *HTTPServer) getTeamsAccessControlMetadata(c *models.ReqContext, teamIDs map[string]bool) (map[string]accesscontrol.Metadata, error) { - if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - hs.log.Warn("could not fetch accesscontrol metadata for teams", "error", err) - return nil, err - } - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs), nil -} - // GET /api/teams/search func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { perPage := c.QueryInt("perpage") @@ -157,8 +141,8 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { teamIDs[strconv.FormatInt(team.Id, 10)] = true } - metadata, err := hs.getTeamsAccessControlMetadata(c, teamIDs) - if err == nil && len(metadata) != 0 { + metadata := hs.getMultiAccessControlMetadata(c, "teams", teamIDs) + if len(metadata) > 0 { for _, team := range query.Result.Teams { team.AccessControl = metadata[strconv.FormatInt(team.Id, 10)] } @@ -170,23 +154,6 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { return response.JSON(200, query.Result) } -func (hs *HTTPServer) getTeamAccessControlMetadata(c *models.ReqContext, teamID int64) (accesscontrol.Metadata, error) { - if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - hs.log.Warn("could not fetch accesscontrol metadata", "team", teamID, "error", err) - return nil, err - } - - key := fmt.Sprintf("%d", teamID) - teamIDs := map[string]bool{key: true} - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs)[key], nil -} - // 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 the user is an admin, this will return models.FilterIgnoreUser (0) @@ -227,8 +194,8 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { return response.Error(500, "Failed to get Team", err) } - metadata, _ := hs.getTeamAccessControlMetadata(c, query.Result.Id) - query.Result.AccessControl = metadata + // Add accesscontrol metadata + query.Result.AccessControl = hs.getAccessControlMetadata(c, "teams", query.Result.Id) query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name) return response.JSON(200, &query.Result) diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index e03170099a6..e39028872e8 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -85,7 +84,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response { return response.Error(400, "User is already added to this team", nil) } - err = addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, cmd.UserId, cmd.OrgId, cmd.TeamId, getPermissionName(cmd.Permission)) + err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, cmd.UserId, cmd.OrgId, cmd.TeamId, getPermissionName(cmd.Permission)) if err != nil { return response.Error(500, "Failed to add Member to Team", err) } @@ -125,7 +124,7 @@ func (hs *HTTPServer) UpdateTeamMember(c *models.ReqContext) response.Response { return response.Error(404, "Team member not found.", nil) } - err = addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission)) + err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission)) if err != nil { return response.Error(500, "Failed to update team member.", err) } @@ -161,7 +160,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response { } teamIDString := strconv.FormatInt(teamId, 10) - if _, err := hs.TeamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil { + if _, err := hs.teamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil { if errors.Is(err, models.ErrTeamNotFound) { return response.Error(404, "Team not found", nil) } @@ -178,7 +177,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response { // addOrUpdateTeamMember adds or updates a team member. // // Stubbable by tests. -var addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64, permission string) error { +var addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService accesscontrol.PermissionsService, userID, orgID, teamID int64, permission string) error { teamIDString := strconv.FormatInt(teamID, 10) if _, err := resourcePermissionService.SetUserPermission(ctx, orgID, accesscontrol.User{ID: userID}, teamIDString, permission); err != nil { return fmt.Errorf("failed setting permissions for user %d in team %d: %w", userID, teamID, err) diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 4246cafa1e9..d2995504d5f 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" @@ -112,7 +111,7 @@ func TestTeamAPIEndpoint(t *testing.T) { teamName := "team foo" addTeamMemberCalled := 0 - addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64, + addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService accesscontrol.PermissionsService, userID, orgID, teamID int64, permission string) error { addTeamMemberCalled++ return nil diff --git a/pkg/api/user.go b/pkg/api/user.go index 64f609fff43..20babbf91cc 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -3,14 +3,12 @@ package api import ( "context" "errors" - "fmt" "net/http" "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -48,33 +46,12 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res query.Result.IsExternal = true } - accessControlMetadata, errAC := hs.getGlobalUserAccessControlMetadata(c, userID) - if errAC != nil { - hs.log.Error("Failed to get access control metadata", "error", errAC) - } - - query.Result.AccessControl = accessControlMetadata + query.Result.AccessControl = hs.getAccessControlMetadata(c, "global:users", userID) query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email) return response.JSON(200, query.Result) } -func (hs *HTTPServer) getGlobalUserAccessControlMetadata(c *models.ReqContext, userID int64) (accesscontrol.Metadata, error) { - if hs.AccessControl == nil || hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - return nil, err - } - - key := fmt.Sprintf("%d", userID) - userIDs := map[string]bool{key: true} - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "global:users", userIDs)[key], nil -} - // GET /api/users/lookup func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Response { query := models.GetUserByLoginQuery{LoginOrEmail: c.Query("loginOrEmail")} diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 2841d5bd5d2..e52fcf09778 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -8,32 +8,33 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" + "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" - "golang.org/x/oauth2" - - "github.com/grafana/grafana/pkg/services/searchusers" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { settings := setting.NewCfg() sqlStore := sqlstore.InitTestDB(t) hs := &HTTPServer{ - Cfg: settings, - SQLStore: sqlStore, + Cfg: settings, + SQLStore: sqlStore, + AccessControl: &acmock.Mock{}, } mockResult := models.SearchUserQueryResult{ diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 82b1bff1ef1..809dd35adf3 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -12,9 +12,6 @@ import ( "github.com/grafana/grafana/pkg/cmd/grafana-cli/services" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/hooks" - "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/setting" @@ -59,14 +56,7 @@ func runDbCommand(command func(commandLine utils.CommandLine, sqlStore *sqlstore return errutil.Wrap("failed to initialize tracer service", err) } - hooksService := hooks.ProvideService() - ossLicensingService := licensing.ProvideService(cfg, hooksService) - featureManager, err := featuremgmt.ProvideManagerService(cfg, ossLicensingService) - if err != nil { - return errutil.Wrap("failed to initialize feature manager service", err) - } - - sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer, featureManager) + sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer) if err != nil { return errutil.Wrap("failed to initialize SQL store", err) } diff --git a/pkg/cmd/grafana-cli/commands/cuetsify_command.go b/pkg/cmd/grafana-cli/commands/cuetsify_command.go index 73ae0d098c7..d71335f2431 100644 --- a/pkg/cmd/grafana-cli/commands/cuetsify_command.go +++ b/pkg/cmd/grafana-cli/commands/cuetsify_command.go @@ -43,6 +43,7 @@ var skipPaths = []string{ "public/app/plugins/panel/dashlist/models.cue", "public/app/plugins/panel/gauge/models.cue", "public/app/plugins/panel/histogram/models.cue", + "public/app/plugins/panel/heatmap-new/models.cue", "public/app/plugins/panel/stat/models.cue", "public/app/plugins/panel/candlestick/models.cue", "public/app/plugins/panel/state-timeline/models.cue", diff --git a/pkg/dashboards/ifaces.go b/pkg/dashboards/ifaces.go deleted file mode 100644 index 93fe150e583..00000000000 --- a/pkg/dashboards/ifaces.go +++ /dev/null @@ -1,23 +0,0 @@ -package dashboards - -import ( - "context" - - "github.com/grafana/grafana/pkg/models" -) - -// Store is a dashboard store. -type Store interface { - // ValidateDashboardBeforeSave validates a dashboard before save. - ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) - // GetFolderByTitle retrieves a dashboard by its title and is used by unified alerting - GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) - GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) - GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) - GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) - SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) - SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) - UpdateDashboardACLCtx(ctx context.Context, uid int64, items []*models.DashboardAcl) error - // SaveAlerts saves dashboard alerts. - SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error -} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 339f4d6b2bb..aad424e3900 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -45,6 +45,7 @@ type OAuthInfo struct { TeamsUrl string AllowSignup bool Name string + Icon string TlsClientCert string TlsClientKey string TlsClientCa string @@ -81,6 +82,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { HostedDomain: sec.Key("hosted_domain").String(), AllowSignup: sec.Key("allow_sign_up").MustBool(), Name: sec.Key("name").MustString(name), + Icon: sec.Key("icon").String(), TlsClientCert: sec.Key("tls_client_cert").String(), TlsClientKey: sec.Key("tls_client_key").String(), TlsClientCa: sec.Key("tls_client_ca").String(), diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index a3ff1276639..a3b2a2f2e89 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -191,28 +191,30 @@ func shouldForceLogin(c *models.ReqContext) bool { return forceLogin } -func OrgAdminFolderAdminOrTeamAdmin(c *models.ReqContext) { - if c.OrgRole == models.ROLE_ADMIN { - return - } +func OrgAdminFolderAdminOrTeamAdmin(ss sqlstore.Store) func(c *models.ReqContext) { + return func(c *models.ReqContext) { + if c.OrgRole == models.ROLE_ADMIN { + return + } - hasAdminPermissionInFoldersQuery := models.HasAdminPermissionInFoldersQuery{SignedInUser: c.SignedInUser} - if err := sqlstore.HasAdminPermissionInFolders(c.Req.Context(), &hasAdminPermissionInFoldersQuery); err != nil { - c.JsonApiErr(500, "Failed to check if user is a folder admin", err) - } + hasAdminPermissionInFoldersQuery := models.HasAdminPermissionInFoldersQuery{SignedInUser: c.SignedInUser} + if err := ss.HasAdminPermissionInFolders(c.Req.Context(), &hasAdminPermissionInFoldersQuery); err != nil { + c.JsonApiErr(500, "Failed to check if user is a folder admin", err) + } - if hasAdminPermissionInFoldersQuery.Result { - return - } + if hasAdminPermissionInFoldersQuery.Result { + return + } - isAdminOfTeamsQuery := models.IsAdminOfTeamsQuery{SignedInUser: c.SignedInUser} - if err := sqlstore.IsAdminOfTeams(c.Req.Context(), &isAdminOfTeamsQuery); err != nil { - c.JsonApiErr(500, "Failed to check if user is a team admin", err) - } + isAdminOfTeamsQuery := models.IsAdminOfTeamsQuery{SignedInUser: c.SignedInUser} + if err := sqlstore.IsAdminOfTeams(c.Req.Context(), &isAdminOfTeamsQuery); err != nil { + c.JsonApiErr(500, "Failed to check if user is a team admin", err) + } - if isAdminOfTeamsQuery.Result { - return - } + if isAdminOfTeamsQuery.Result { + return + } - accessForbidden(c) + accessForbidden(c) + } } diff --git a/pkg/models/dashboard_thumbs.go b/pkg/models/dashboard_thumbs.go index 954a07b1a42..0935d0c16e0 100644 --- a/pkg/models/dashboard_thumbs.go +++ b/pkg/models/dashboard_thumbs.go @@ -117,6 +117,10 @@ type DashboardWithStaleThumbnail struct { Slug string } +type FindDashboardThumbnailCountCommand struct { + Result int64 +} + type FindDashboardsWithStaleThumbnailsCommand struct { IncludeManuallyUploadedThumbnails bool Theme Theme diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index ff11aa463b3..358f0ad1e8b 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -456,7 +456,3 @@ type GetDashboardRefByIdQuery struct { Id int64 Result *DashboardRef } - -type UnprovisionDashboardCommand struct { - Id int64 -} diff --git a/pkg/plugins/manager/dashboards_test.go b/pkg/plugins/manager/dashboards_test.go index 55397f0bff2..d801c65e78b 100644 --- a/pkg/plugins/manager/dashboards_test.go +++ b/pkg/plugins/manager/dashboards_test.go @@ -11,6 +11,9 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/services/dashboards/database" + service "github.com/grafana/grafana/pkg/services/dashboards/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -24,8 +27,9 @@ func TestGetPluginDashboards(t *testing.T) { }, } pmCfg := plugins.FromGrafanaCfg(cfg) + dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{})) pm, err := ProvideService(cfg, loader.New(pmCfg, nil, - signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{})) + signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{}), dashboardService) require.NoError(t, err) bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardQuery) error { diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index f906a9572f2..03c0819d90b 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/url" "os" "path" @@ -240,7 +241,7 @@ func createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDi Class: class, } - plugin.SetLogger(logger.New("pluginID", plugin.ID)) + plugin.SetLogger(log.New(fmt.Sprintf("plugin.%s", plugin.ID))) setImages(plugin) return plugin diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index 616f862d97b..d74bad4f744 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -9,12 +9,12 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/instrumentation" "github.com/grafana/grafana/pkg/plugins/manager/installer" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -30,35 +30,38 @@ var _ plugins.StaticRouteResolver = (*PluginManager)(nil) var _ plugins.RendererManager = (*PluginManager)(nil) type PluginManager struct { - cfg *plugins.Cfg - store map[string]*plugins.Plugin - pluginInstaller plugins.Installer - pluginLoader plugins.Loader - pluginsMu sync.RWMutex - pluginPaths map[plugins.Class][]string - log log.Logger + cfg *plugins.Cfg + store map[string]*plugins.Plugin + pluginInstaller plugins.Installer + pluginLoader plugins.Loader + pluginsMu sync.RWMutex + pluginPaths map[plugins.Class][]string + dashboardService dashboards.DashboardService + log log.Logger } -func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader) (*PluginManager, error) { +func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader, dashboardService dashboards.DashboardService) (*PluginManager, error) { pm := New(plugins.FromGrafanaCfg(grafanaCfg), map[plugins.Class][]string{ plugins.Core: corePluginPaths(grafanaCfg), plugins.Bundled: {grafanaCfg.BundledPluginsPath}, plugins.External: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...), - }, pluginLoader) + }, pluginLoader, dashboardService) if err := pm.Init(); err != nil { return nil, err } return pm, nil } -func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader) *PluginManager { +func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader, + dashboardService dashboards.DashboardService) *PluginManager { return &PluginManager{ - cfg: cfg, - pluginLoader: pluginLoader, - pluginPaths: pluginPaths, - store: make(map[string]*plugins.Plugin), - log: log.New("plugin.manager"), - pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)), + cfg: cfg, + pluginLoader: pluginLoader, + pluginPaths: pluginPaths, + store: make(map[string]*plugins.Plugin), + log: log.New("plugin.manager"), + pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)), + dashboardService: dashboardService, } } diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index beb81cb7a96..e0c871f0428 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -7,16 +7,14 @@ import ( "strings" "testing" - "github.com/grafana/grafana/pkg/infra/tracing" - "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/signature" + service "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/searchV2" @@ -37,6 +35,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/prometheus" "github.com/grafana/grafana/pkg/tsdb/tempo" "github.com/grafana/grafana/pkg/tsdb/testdatasource" + "go.opentelemetry.io/otel/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -95,7 +94,7 @@ func TestPluginManager_int_init(t *testing.T) { pmCfg := plugins.FromGrafanaCfg(cfg) pm, err := ProvideService(cfg, loader.New(pmCfg, license, signature.NewUnsignedAuthorizer(pmCfg), - provider.ProvideService(coreRegistry))) + provider.ProvideService(coreRegistry)), &service.DashboardServiceImpl{}) require.NoError(t, err) verifyCorePluginCatalogue(t, pm) @@ -120,6 +119,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) { "gettingstarted": {}, "graph": {}, "heatmap": {}, + "heatmap-new": {}, "histogram": {}, "icon": {}, "live": {}, diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index d0b5d9dc813..6a35bd463d0 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -12,6 +12,9 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/services/dashboards/database" + service "github.com/grafana/grafana/pkg/services/dashboards/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -466,7 +469,8 @@ func TestPluginManager_lifecycle_unmanaged(t *testing.T) { func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager { t.Helper() - pm := New(&plugins.Cfg{}, nil, &fakeLoader{}) + dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{})) + pm := New(&plugins.Cfg{}, nil, &fakeLoader{}, dashboardService) for _, cb := range cbs { cb(pm) @@ -520,7 +524,8 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS cfg.Azure.ManagedIdentityClientId = "client-id" loader := &fakeLoader{} - manager := New(cfg, nil, loader) + dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{})) + manager := New(cfg, nil, loader, dashboardService) manager.pluginLoader = loader ctx := &managerScenarioCtx{ manager: manager, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 3e6f8dbb1e0..8f57ceb3a29 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -27,13 +27,15 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/plugincontext" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/dashboardimport" dashboardimportservice "github.com/grafana/grafana/pkg/services/dashboardimport/service" + "github.com/grafana/grafana/pkg/services/dashboards" + dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" @@ -199,7 +201,13 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(teamguardian.TeamGuardian), new(*teamguardianManager.Service)), featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, - resourceservices.ProvideResourceServices, + dashboardservice.ProvideDashboardService, + dashboardservice.ProvideFolderService, + dashboardstore.ProvideDashboardStore, + wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)), + wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)), + wire.Bind(new(dashboards.FolderService), new(*dashboardservice.FolderServiceImpl)), + wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)), dashboardimportservice.ProvideService, wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)), plugindashboards.ProvideService, diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 6c694411593..292b666c3f5 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -77,6 +77,8 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(ldap.Groups), new(*ldap.OSSGroups)), api.ProvideDatasourcePermissionsService, wire.Bind(new(api.DatasourcePermissionsService), new(*api.OSSDatasourcePermissionsService)), + ossaccesscontrol.ProvidePermissionsServices, + wire.Bind(new(accesscontrol.PermissionsServices), new(*ossaccesscontrol.PermissionsService)), ) var wireExtsSet = wire.NewSet( diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index c05280f5693..03ae32a23a2 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -37,7 +37,12 @@ type PermissionsProvider interface { GetUserPermissions(ctx context.Context, query GetUserPermissionsQuery) ([]*Permission, error) } -type ResourcePermissionsService interface { +type PermissionsServices interface { + GetTeamService() PermissionsService + GetDataSourceService() PermissionsService +} + +type PermissionsService interface { // GetPermissions returns all permissions for given resourceID GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]ResourcePermission, error) // SetUserPermission sets permission on resource for a user @@ -146,7 +151,7 @@ func addActionToMetadata(allMetadata map[string]Metadata, action, id string) map } // GetResourcesMetadata returns a map of accesscontrol metadata, listing for each resource, users available actions -func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resource string, resourceIDs map[string]bool) map[string]Metadata { +func GetResourcesMetadata(ctx context.Context, permissions map[string][]string, resource string, ids map[string]bool) map[string]Metadata { allScope := GetResourceAllScope(resource) allIDScope := GetResourceAllIDScope(resource) @@ -157,16 +162,18 @@ func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resour // Loop through permissions once result := map[string]Metadata{} - for _, p := range permissions { - if p.Scope == "*" || p.Scope == allScope || p.Scope == allIDScope { - // Add global action to all resources - for id := range resourceIDs { - result = addActionToMetadata(result, p.Action, id) - } - } else { - if len(p.Scope) > idIndex && strings.HasPrefix(p.Scope, idPrefix) && resourceIDs[p.Scope[idIndex:]] { - // Add action to a specific resource - result = addActionToMetadata(result, p.Action, p.Scope[idIndex:]) + for action, scopes := range permissions { + for _, scope := range scopes { + if scope == "*" || scope == allScope || scope == allIDScope { + // Add global action to all resources + for id := range ids { + result = addActionToMetadata(result, action, id) + } + } else { + if len(scope) > idIndex && strings.HasPrefix(scope, idPrefix) && ids[scope[idIndex:]] { + // Add action to a specific resource + result = addActionToMetadata(result, action, scope[idIndex:]) + } } } } diff --git a/pkg/services/accesscontrol/accesscontrol_bench_test.go b/pkg/services/accesscontrol/accesscontrol_bench_test.go index 2259508f67e..b6942e664d3 100644 --- a/pkg/services/accesscontrol/accesscontrol_bench_test.go +++ b/pkg/services/accesscontrol/accesscontrol_bench_test.go @@ -9,17 +9,17 @@ import ( "github.com/stretchr/testify/assert" ) -func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) ([]*Permission, map[string]bool) { - res := make([]*Permission, resourceCount*permissionPerResource) +func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) (map[string][]string, map[string]bool) { + res := map[string][]string{} ids := make(map[string]bool, resourceCount) - for r := 0; r < resourceCount; r++ { - for p := 0; p < permissionPerResource; p++ { - perm := Permission{Action: fmt.Sprintf("resources:action%v", p), Scope: fmt.Sprintf("resources:id:%v", r)} - id := r*permissionPerResource + p - res[id] = &perm + for p := 0; p < permissionPerResource; p++ { + action := fmt.Sprintf("resources:action%v", p) + for r := 0; r < resourceCount; r++ { + scope := fmt.Sprintf("resources:id:%v", r) + res[action] = append(res[action], scope) + ids[fmt.Sprintf("%d", r)] = true } - ids[fmt.Sprintf("%d", r)] = true } return res, ids @@ -40,23 +40,23 @@ func benchGetMetadata(b *testing.B, resourceCount, permissionPerResource int) { } // Lots of permissions -func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0017s/op -func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.016s/op -func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.17s/op +func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0022s/op +func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.019s/op +func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.25s/op func BenchmarkGetResourcesMetadata_10_1000000(b *testing.B) { if testing.Short() { b.Skip("Skipping benchmark in short mode") } benchGetMetadata(b, 10, 1000000) -} // ~3.89s/op +} // ~5.8s/op // Lots of resources func BenchmarkGetResourcesMetadata_1000_10(b *testing.B) { benchGetMetadata(b, 1000, 10) } // ~0,0023s/op -func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.021s/op -func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.22s/op +func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.022s/op +func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.26s/op func BenchmarkGetResourcesMetadata_1000000_10(b *testing.B) { if testing.Short() { b.Skip("Skipping benchmark in short mode") } benchGetMetadata(b, 1000000, 10) -} // ~2.8s/op +} // ~4.1s/op diff --git a/pkg/services/accesscontrol/accesscontrol_test.go b/pkg/services/accesscontrol/accesscontrol_test.go index e373b08d172..eef8e0b1e11 100644 --- a/pkg/services/accesscontrol/accesscontrol_test.go +++ b/pkg/services/accesscontrol/accesscontrol_test.go @@ -12,7 +12,7 @@ func TestGetResourcesMetadata(t *testing.T) { desc string resource string resourcesIDs map[string]bool - permissions []*Permission + permissions map[string][]string expected map[string]Metadata }{ { @@ -24,10 +24,10 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should return no permission for resources 1,2,3 given the user has permissions for 4 only", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "id", "4")}, - {Action: "resources:action2", Scope: Scope("resources", "id", "4")}, - {Action: "resources:action3", Scope: Scope("resources", "id", "4")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "4")}, + "resources:action2": {Scope("resources", "id", "4")}, + "resources:action3": {Scope("resources", "id", "4")}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{}, @@ -35,10 +35,10 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should only return permissions for resources 1 and 2, given the user has no permissions for 3", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "resources:action2", Scope: Scope("resources", "id", "2")}, - {Action: "resources:action3", Scope: Scope("resources", "id", "2")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "1")}, + "resources:action2": {Scope("resources", "id", "2")}, + "resources:action3": {Scope("resources", "id", "2")}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{ @@ -49,13 +49,13 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should return permissions with global scopes for resources 1,2,3", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action4", Scope: Scope("resources", "id", "*")}, - {Action: "resources:action5", Scope: Scope("resources", "*")}, - {Action: "resources:action6", Scope: "*"}, - {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "resources:action2", Scope: Scope("resources", "id", "2")}, - {Action: "resources:action3", Scope: Scope("resources", "id", "2")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "1")}, + "resources:action2": {Scope("resources", "id", "2")}, + "resources:action3": {Scope("resources", "id", "2")}, + "resources:action4": {Scope("resources", "id", "*")}, + "resources:action5": {Scope("resources", "*")}, + "resources:action6": {"*"}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{ @@ -67,11 +67,10 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should correctly filter out irrelevant permissions for resources 1,2,3", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "otherresources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "resources:action2", Scope: Scope("otherresources", "id", "*")}, - {Action: "otherresources:action1", Scope: Scope("otherresources", "id", "*")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "1")}, + "resources:action2": {Scope("otherresources", "id", "*")}, + "otherresources:action1": {Scope("resources", "id", "1"), Scope("otherresources", "id", "*")}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{ @@ -81,9 +80,8 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should correctly handle permissions with multilayer scope", resource: "resources:sub", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "1")}, - {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "123")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "sub", "id", "1"), Scope("resources", "sub", "id", "123")}, }, resourcesIDs: map[string]bool{"1": true, "123": true}, expected: map[string]Metadata{ diff --git a/pkg/services/accesscontrol/mock/permissions_services_mock.go b/pkg/services/accesscontrol/mock/permissions_services_mock.go new file mode 100644 index 00000000000..b5d084bdce9 --- /dev/null +++ b/pkg/services/accesscontrol/mock/permissions_services_mock.go @@ -0,0 +1,27 @@ +package mock + +import ( + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +var _ accesscontrol.PermissionsServices = new(PermissionsServicesMock) + +func NewPermissionsServicesMock() *PermissionsServicesMock { + return &PermissionsServicesMock{ + teams: &MockPermissionsService{}, + datasources: &MockPermissionsService{}, + } +} + +type PermissionsServicesMock struct { + teams *MockPermissionsService + datasources *MockPermissionsService +} + +func (p PermissionsServicesMock) GetTeamService() accesscontrol.PermissionsService { + return p.teams +} + +func (p PermissionsServicesMock) GetDataSourceService() accesscontrol.PermissionsService { + return p.datasources +} diff --git a/pkg/services/accesscontrol/mock/service_mock.go b/pkg/services/accesscontrol/mock/service_mock.go new file mode 100644 index 00000000000..c5f57caf056 --- /dev/null +++ b/pkg/services/accesscontrol/mock/service_mock.go @@ -0,0 +1,40 @@ +package mock + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +var _ accesscontrol.PermissionsService = new(MockPermissionsService) + +type MockPermissionsService struct { + mock.Mock +} + +func (m *MockPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, resourceID) + return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, user, resourceID, permission) + return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, teamID, resourceID, permission) + return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, builtInRole, resourceID, permission) + return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, resourceID, commands) + return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) +} diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index efe6dc98118..51a928495c7 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/api" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/prometheus/client_golang/prometheus" ) @@ -116,7 +115,7 @@ func (ac *OSSAccessControlService) GetUserPermissions(ctx context.Context, user OrgID: user.OrgId, UserID: user.UserId, Roles: ac.GetUserBuiltInRoles(user), - Actions: resourceservices.TeamAdminActions, + Actions: TeamAdminActions, }) if err != nil { return nil, err diff --git a/pkg/services/accesscontrol/resourceservices/resource_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go similarity index 57% rename from pkg/services/accesscontrol/resourceservices/resource_services.go rename to pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index 3cf2adfb95f..578819c5cfa 100644 --- a/pkg/services/accesscontrol/resourceservices/resource_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -1,4 +1,4 @@ -package resourceservices +package ossaccesscontrol import ( "context" @@ -12,23 +12,26 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" ) -func ProvideResourceServices(router routing.RouteRegister, sql *sqlstore.SQLStore, ac accesscontrol.AccessControl, store resourcepermissions.Store) (*ResourceServices, error) { +func ProvidePermissionsServices(router routing.RouteRegister, sql *sqlstore.SQLStore, ac accesscontrol.AccessControl, store resourcepermissions.Store) (*PermissionsService, error) { teamPermissions, err := ProvideTeamPermissions(router, sql, ac, store) if err != nil { return nil, err } - return &ResourceServices{services: map[string]*resourcepermissions.Service{ - "teams": teamPermissions, - }}, nil + return &PermissionsService{teams: teamPermissions, datasources: provideEmptyPermissionsService()}, nil } -type ResourceServices struct { - services map[string]*resourcepermissions.Service +type PermissionsService struct { + teams accesscontrol.PermissionsService + datasources accesscontrol.PermissionsService } -func (s *ResourceServices) GetTeamService() *resourcepermissions.Service { - return s.services["teams"] +func (s *PermissionsService) GetTeamService() accesscontrol.PermissionsService { + return s.teams +} + +func (s *PermissionsService) GetDataSourceService() accesscontrol.PermissionsService { + return s.datasources } var ( @@ -101,3 +104,31 @@ func ProvideTeamPermissions(router routing.RouteRegister, sql *sqlstore.SQLStore return resourcepermissions.New(options, router, ac, store, sql) } + +func provideEmptyPermissionsService() accesscontrol.PermissionsService { + return &emptyPermissionsService{} +} + +var _ accesscontrol.PermissionsService = new(emptyPermissionsService) + +type emptyPermissionsService struct{} + +func (e emptyPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole string, resourceID string, permission string) (*accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { + return nil, nil +} diff --git a/pkg/services/accesscontrol/resourcepermissions/service_mock.go b/pkg/services/accesscontrol/resourcepermissions/service_mock.go deleted file mode 100644 index 41f78f4efcb..00000000000 --- a/pkg/services/accesscontrol/resourcepermissions/service_mock.go +++ /dev/null @@ -1,40 +0,0 @@ -package resourcepermissions - -import ( - "context" - - "github.com/stretchr/testify/mock" - - "github.com/grafana/grafana/pkg/services/accesscontrol" -) - -var _ accesscontrol.ResourcePermissionsService = new(MockService) - -type MockService struct { - mock.Mock -} - -func (m *MockService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, resourceID) - return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, user, resourceID, permission) - return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, teamID, resourceID, permission) - return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, builtInRole, resourceID, permission) - return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, resourceID, commands) - return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) -} diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index 1fcfeb3d836..eead5a7b046 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -13,16 +13,15 @@ import ( "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/schemaloader" - "github.com/grafana/grafana/pkg/services/sqlstore" ) -func ProvideService(sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister, +func ProvideService(routeRegister routing.RouteRegister, quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService, pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, - libraryPanelService librarypanels.Service) *ImportDashboardService { + libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService) *ImportDashboardService { s := &ImportDashboardService{ pluginDashboardManager: pluginDashboardManager, - dashboardService: dashboards.NewService(sqlStore), + dashboardService: dashboardService, libraryPanelService: libraryPanelService, } diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go deleted file mode 100644 index 0a9b7b4f024..00000000000 --- a/pkg/services/dashboards/acl_service.go +++ /dev/null @@ -1,51 +0,0 @@ -package dashboards - -import ( - "context" - "time" - - "github.com/grafana/grafana/pkg/models" -) - -func (dr *dashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, userID int64, dashboardID int64, setViewAndEditPermissions bool) error { - rtEditor := models.ROLE_EDITOR - rtViewer := models.ROLE_VIEWER - - items := []*models.DashboardAcl{ - { - OrgID: orgID, - DashboardID: dashboardID, - UserID: userID, - Permission: models.PERMISSION_ADMIN, - Created: time.Now(), - Updated: time.Now(), - }, - } - - if setViewAndEditPermissions { - items = append(items, - &models.DashboardAcl{ - OrgID: orgID, - DashboardID: dashboardID, - Role: &rtEditor, - Permission: models.PERMISSION_EDIT, - Created: time.Now(), - Updated: time.Now(), - }, - &models.DashboardAcl{ - OrgID: orgID, - DashboardID: dashboardID, - Role: &rtViewer, - Permission: models.PERMISSION_VIEW, - Created: time.Now(), - Updated: time.Now(), - }, - ) - } - - if err := dr.dashboardStore.UpdateDashboardACLCtx(ctx, dashboardID, items); err != nil { - return err - } - - return nil -} diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go new file mode 100644 index 00000000000..f3cca22bfe4 --- /dev/null +++ b/pkg/services/dashboards/dashboard.go @@ -0,0 +1,47 @@ +package dashboards + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +// DashboardService is a service for operating on dashboards. +type DashboardService interface { + SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) + ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) + DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error + MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error + BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) + UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error +} + +//go:generate mockery --name DashboardProvisioningService --structname FakeDashboardProvisioning --inpackage --filename dashboard_provisioning_mock.go +// DashboardProvisioningService is a service for operating on provisioned dashboards. +type DashboardProvisioningService interface { + SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) + SaveFolderForProvisionedDashboards(context.Context, *SaveDashboardDTO) (*models.Dashboard, error) + GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) + GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) + GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) + UnprovisionDashboard(ctx context.Context, dashboardID int64) error + DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error +} + +//go:generate mockery --name Store --structname FakeDashboardStore --output database --outpkg database --filename database_mock.go +// Store is a dashboard store. +type Store interface { + // ValidateDashboardBeforeSave validates a dashboard before save. + ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) + // GetFolderByTitle retrieves a dashboard by its title and is used by unified alerting + GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) + GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) + GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) + GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) + SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) + SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) + UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error + // SaveAlerts saves dashboard alerts. + SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error + UnprovisionDashboard(ctx context.Context, id int64) error +} diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go new file mode 100644 index 00000000000..46ae5a09745 --- /dev/null +++ b/pkg/services/dashboards/dashboard_provisioning_mock.go @@ -0,0 +1,158 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package dashboards + +import ( + context "context" + + models "github.com/grafana/grafana/pkg/models" + mock "github.com/stretchr/testify/mock" +) + +// FakeDashboardProvisioning is an autogenerated mock type for the DashboardProvisioningService type +type FakeDashboardProvisioning struct { + mock.Mock +} + +// DeleteProvisionedDashboard provides a mock function with given fields: ctx, dashboardID, orgID +func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error { + ret := _m.Called(ctx, dashboardID, orgID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok { + r0 = rf(ctx, dashboardID, orgID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetProvisionedDashboardData provides a mock function with given fields: name +func (_m *FakeDashboardProvisioning) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + ret := _m.Called(name) + + var r0 []*models.DashboardProvisioning + if rf, ok := ret.Get(0).(func(string) []*models.DashboardProvisioning); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.DashboardProvisioning) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetProvisionedDashboardDataByDashboardID provides a mock function with given fields: dashboardID +func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { + ret := _m.Called(dashboardID) + + var r0 *models.DashboardProvisioning + if rf, ok := ret.Get(0).(func(int64) *models.DashboardProvisioning); ok { + r0 = rf(dashboardID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.DashboardProvisioning) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int64) error); ok { + r1 = rf(dashboardID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetProvisionedDashboardDataByDashboardUID provides a mock function with given fields: orgID, dashboardUID +func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { + ret := _m.Called(orgID, dashboardUID) + + var r0 *models.DashboardProvisioning + if rf, ok := ret.Get(0).(func(int64, string) *models.DashboardProvisioning); ok { + r0 = rf(orgID, dashboardUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.DashboardProvisioning) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int64, string) error); ok { + r1 = rf(orgID, dashboardUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveFolderForProvisionedDashboards provides a mock function with given fields: _a0, _a1 +func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *SaveDashboardDTO) (*models.Dashboard, error) { + ret := _m.Called(_a0, _a1) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO) *models.Dashboard); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveProvisionedDashboard provides a mock function with given fields: ctx, dto, provisioning +func (_m *FakeDashboardProvisioning) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + ret := _m.Called(ctx, dto, provisioning) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, *models.DashboardProvisioning) *models.Dashboard); ok { + r0 = rf(ctx, dto, provisioning) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO, *models.DashboardProvisioning) error); ok { + r1 = rf(ctx, dto, provisioning) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UnprovisionDashboard provides a mock function with given fields: ctx, dashboardID +func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, dashboardID int64) error { + ret := _m.Called(ctx, dashboardID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, dashboardID) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go new file mode 100644 index 00000000000..9d6331879cb --- /dev/null +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -0,0 +1,44 @@ +package dashboards + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +type FakeDashboardService struct { + DashboardService + + SaveDashboardResult *models.Dashboard + SaveDashboardError error + SavedDashboards []*SaveDashboardDTO + ProvisionedDashData *models.DashboardProvisioning +} + +func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) { + s.SavedDashboards = append(s.SavedDashboards, dto) + + if s.SaveDashboardResult == nil && s.SaveDashboardError == nil { + s.SaveDashboardResult = dto.Dashboard + } + + return s.SaveDashboardResult, s.SaveDashboardError +} + +func (s *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) { + return s.SaveDashboard(ctx, dto, true) +} + +func (s *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { + for index, dash := range s.SavedDashboards { + if dash.Dashboard.Id == dashboardId && dash.OrgId == orgId { + s.SavedDashboards = append(s.SavedDashboards[:index], s.SavedDashboards[index+1:]...) + break + } + } + return nil +} + +func (s *FakeDashboardService) GetProvisionedDashboardDataByDashboardID(id int64) (*models.DashboardProvisioning, error) { + return s.ProvisionedDashData, nil +} diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go new file mode 100644 index 00000000000..60b0b44d5f1 --- /dev/null +++ b/pkg/services/dashboards/database/database.go @@ -0,0 +1,611 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util" +) + +type DashboardStore struct { + sqlStore *sqlstore.SQLStore + log log.Logger +} + +func ProvideDashboardStore(sqlStore *sqlstore.SQLStore) *DashboardStore { + return &DashboardStore{sqlStore: sqlStore, log: log.New("dashboard-store")} +} + +func (d *DashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) { + isParentFolderChanged := false + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + var err error + isParentFolderChanged, err = getExistingDashboardByIdOrUidForUpdate(sess, dashboard, d.sqlStore.Dialect, overwrite) + if err != nil { + return err + } + + isParentFolderChanged, err = getExistingDashboardByTitleAndFolder(sess, dashboard, d.sqlStore.Dialect, overwrite, + isParentFolderChanged) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return false, err + } + + return isParentFolderChanged, nil +} + +func (d *DashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { + if title == "" { + return nil, models.ErrDashboardIdentifierNotSet + } + + // 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} + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + has, err := sess.Table(&models.Dashboard{}).Where("is_folder = " + d.sqlStore.Dialect.BooleanStr(true)).Where("folder_id=0").Get(&dashboard) + if err != nil { + return err + } + if !has { + return models.ErrDashboardNotFound + } + dashboard.SetId(dashboard.Id) + dashboard.SetUid(dashboard.Uid) + return nil + }) + return &dashboard, err +} + +func (d *DashboardStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { + var data models.DashboardProvisioning + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + _, err := sess.Where("dashboard_id = ?", dashboardID).Get(&data) + return err + }) + + if data.DashboardId == 0 { + return nil, nil + } + return &data, err +} + +func (d *DashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { + var provisionedDashboard models.DashboardProvisioning + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + var dashboard models.Dashboard + exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard) + if err != nil { + return err + } + if !exists { + return models. + ErrDashboardNotFound + } + exists, err = sess.Where("dashboard_id = ?", dashboard.Id).Get(&provisionedDashboard) + if err != nil { + return err + } + if !exists { + return models.ErrProvisionedDashboardNotFound + } + return nil + }) + return &provisionedDashboard, err +} + +func (d *DashboardStore) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + var result []*models.DashboardProvisioning + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + return sess.Where("name = ?", name).Find(&result) + }) + return result, err +} + +func (d *DashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + if err := saveDashboard(sess, &cmd); err != nil { + return err + } + + if provisioning.Updated == 0 { + provisioning.Updated = cmd.Result.Updated.Unix() + } + + return saveProvisionedData(sess, provisioning, cmd.Result) + }) + + return cmd.Result, err +} + +func (d *DashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) { + err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + return saveDashboard(sess, &cmd) + }) + return cmd.Result, err +} + +func (d *DashboardStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error { + return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // delete existing items + _, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", dashboardID) + if err != nil { + return fmt.Errorf("deleting from dashboard_acl failed: %w", err) + } + + for _, item := range items { + if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { + return models.ErrDashboardAclInfoMissing + } + + if item.DashboardID == 0 { + return models.ErrDashboardPermissionDashboardEmpty + } + + sess.Nullable("user_id", "team_id") + if _, err := sess.Insert(item); err != nil { + return err + } + } + + // Update dashboard HasAcl flag + dashboard := models.Dashboard{HasAcl: true} + _, err = sess.Cols("has_acl").Where("id=?", dashboardID).Update(&dashboard) + return err + }) +} + +func (d *DashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { + return d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + existingAlerts, err := GetAlertsByDashboardId2(dashID, sess) + if err != nil { + return err + } + + if err := updateAlerts(existingAlerts, alerts, sess, d.log); err != nil { + return err + } + + if err := deleteMissingAlerts(existingAlerts, alerts, sess, d.log); err != nil { + return err + } + + return nil + }) +} + +// UnprovisionDashboard removes row in dashboard_provisioning for the dashboard making it seem as if manually created. +// The dashboard will still have `created_by = -1` to see it was not created by any particular user. +func (d *DashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { + return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Where("dashboard_id = ?", id).Delete(&models.DashboardProvisioning{}) + return err + }) +} + +func getExistingDashboardByIdOrUidForUpdate(sess *sqlstore.DBSession, dash *models.Dashboard, dialect migrator.Dialect, overwrite bool) (bool, error) { + dashWithIdExists := false + isParentFolderChanged := false + var existingById models.Dashboard + + if dash.Id > 0 { + var err error + dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) + if err != nil { + return false, fmt.Errorf("SQL query for existing dashboard by ID failed: %w", err) + } + + if !dashWithIdExists { + return false, models.ErrDashboardNotFound + } + + if dash.Uid == "" { + dash.SetUid(existingById.Uid) + } + } + + dashWithUidExists := false + var existingByUid models.Dashboard + + if dash.Uid != "" { + var err error + dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) + if err != nil { + return false, fmt.Errorf("SQL query for existing dashboard by UID failed: %w", err) + } + } + + if dash.FolderId > 0 { + var existingFolder models.Dashboard + folderExists, err := sess.Where("org_id=? AND id=? AND is_folder=?", dash.OrgId, dash.FolderId, + dialect.BooleanStr(true)).Get(&existingFolder) + if err != nil { + return false, fmt.Errorf("SQL query for folder failed: %w", err) + } + + if !folderExists { + return false, models.ErrDashboardFolderNotFound + } + } + + if !dashWithIdExists && !dashWithUidExists { + return false, nil + } + + if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { + return false, models.ErrDashboardWithSameUIDExists + } + + existing := existingById + + if !dashWithIdExists && dashWithUidExists { + dash.SetId(existingByUid.Id) + dash.SetUid(existingByUid.Uid) + existing = existingByUid + + if !dash.IsFolder { + isParentFolderChanged = true + } + } + + if (existing.IsFolder && !dash.IsFolder) || + (!existing.IsFolder && dash.IsFolder) { + return isParentFolderChanged, models.ErrDashboardTypeMismatch + } + + if !dash.IsFolder && dash.FolderId != existing.FolderId { + isParentFolderChanged = true + } + + // check for is someone else has written in between + if dash.Version != existing.Version { + if overwrite { + dash.SetVersion(existing.Version) + } else { + return isParentFolderChanged, models.ErrDashboardVersionMismatch + } + } + + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && !overwrite { + return isParentFolderChanged, models.UpdatePluginDashboardError{PluginId: existing.PluginId} + } + + return isParentFolderChanged, nil +} + +func getExistingDashboardByTitleAndFolder(sess *sqlstore.DBSession, dash *models.Dashboard, dialect migrator.Dialect, overwrite, + isParentFolderChanged bool) (bool, error) { + var existing models.Dashboard + exists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, + dialect.BooleanStr(true), dash.FolderId).Get(&existing) + if err != nil { + return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by org ID or folder ID failed: %w", err) + } + + if exists && dash.Id != existing.Id { + if existing.IsFolder && !dash.IsFolder { + return isParentFolderChanged, models.ErrDashboardWithSameNameAsFolder + } + + if !existing.IsFolder && dash.IsFolder { + return isParentFolderChanged, models.ErrDashboardFolderWithSameNameAsDashboard + } + + if !dash.IsFolder && (dash.FolderId != existing.FolderId || dash.Id == 0) { + isParentFolderChanged = true + } + + if overwrite { + dash.SetId(existing.Id) + dash.SetUid(existing.Uid) + dash.SetVersion(existing.Version) + } else { + return isParentFolderChanged, models.ErrDashboardWithSameNameInFolderExists + } + } + + return isParentFolderChanged, nil +} + +func saveDashboard(sess *sqlstore.DBSession, cmd *models.SaveDashboardCommand) error { + dash := cmd.GetDashboardModel() + + userId := cmd.UserId + + if userId == 0 { + userId = -1 + } + + if dash.Id > 0 { + var existing models.Dashboard + dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) + if err != nil { + return err + } + if !dashWithIdExists { + return models.ErrDashboardNotFound + } + + // check for is someone else has written in between + if dash.Version != existing.Version { + if cmd.Overwrite { + dash.SetVersion(existing.Version) + } else { + return models.ErrDashboardVersionMismatch + } + } + + // do not allow plugin dashboard updates without overwrite flag + if existing.PluginId != "" && !cmd.Overwrite { + return models.UpdatePluginDashboardError{PluginId: existing.PluginId} + } + } + + if dash.Uid == "" { + uid, err := generateNewDashboardUid(sess, dash.OrgId) + if err != nil { + return err + } + dash.SetUid(uid) + } + + parentVersion := dash.Version + var affectedRows int64 + var err error + + if dash.Id == 0 { + dash.SetVersion(1) + dash.Created = time.Now() + dash.CreatedBy = userId + dash.Updated = time.Now() + dash.UpdatedBy = userId + metrics.MApiDashboardInsert.Inc() + affectedRows, err = sess.Insert(dash) + } else { + dash.SetVersion(dash.Version + 1) + + if !cmd.UpdatedAt.IsZero() { + dash.Updated = cmd.UpdatedAt + } else { + dash.Updated = time.Now() + } + + dash.UpdatedBy = userId + + affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) + } + + if err != nil { + return err + } + + if affectedRows == 0 { + return models.ErrDashboardNotFound + } + + dashVersion := &models.DashboardVersion{ + DashboardId: dash.Id, + ParentVersion: parentVersion, + RestoredFrom: cmd.RestoredFrom, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, + } + + // insert version entry + if affectedRows, err = sess.Insert(dashVersion); err != nil { + return err + } else if affectedRows == 0 { + return models.ErrDashboardNotFound + } + + // delete existing tags + _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) + if err != nil { + return err + } + + // insert new tags + tags := dash.GetTags() + if len(tags) > 0 { + for _, tag := range tags { + if _, err := sess.Insert(&sqlstore.DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { + return err + } + } + } + + cmd.Result = dash + + return nil +} + +func generateNewDashboardUid(sess *sqlstore.DBSession, orgId int64) (string, error) { + for i := 0; i < 3; i++ { + uid := util.GenerateShortUID() + + exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&models.Dashboard{}) + if err != nil { + return "", err + } + + if !exists { + return uid, nil + } + } + + return "", models.ErrDashboardFailedGenerateUniqueUid +} + +func saveProvisionedData(sess *sqlstore.DBSession, provisioning *models.DashboardProvisioning, dashboard *models.Dashboard) error { + result := &models.DashboardProvisioning{} + + exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, provisioning.Name).Get(result) + if err != nil { + return err + } + + provisioning.Id = result.Id + provisioning.DashboardId = dashboard.Id + + if exist { + _, err = sess.ID(result.Id).Update(provisioning) + } else { + _, err = sess.Insert(provisioning) + } + + return err +} + +func GetAlertsByDashboardId2(dashboardId int64, sess *sqlstore.DBSession) ([]*models.Alert, error) { + alerts := make([]*models.Alert, 0) + err := sess.Where("dashboard_id = ?", dashboardId).Find(&alerts) + + if err != nil { + return []*models.Alert{}, err + } + + return alerts, nil +} + +func updateAlerts(existingAlerts []*models.Alert, alerts []*models.Alert, sess *sqlstore.DBSession, log log.Logger) error { + for _, alert := range alerts { + update := false + var alertToUpdate *models.Alert + + for _, k := range existingAlerts { + if alert.PanelId == k.PanelId { + update = true + alert.Id = k.Id + alertToUpdate = k + break + } + } + + if update { + if alertToUpdate.ContainsUpdates(alert) { + alert.Updated = time.Now() + alert.State = alertToUpdate.State + sess.MustCols("message", "for") + + _, err := sess.ID(alert.Id).Update(alert) + if err != nil { + return err + } + + log.Debug("Alert updated", "name", alert.Name, "id", alert.Id) + } + } else { + alert.Updated = time.Now() + alert.Created = time.Now() + alert.State = models.AlertStateUnknown + alert.NewStateDate = time.Now() + + _, err := sess.Insert(alert) + if err != nil { + return err + } + + log.Debug("Alert inserted", "name", alert.Name, "id", alert.Id) + } + tags := alert.GetTagsFromSettings() + if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alert.Id); err != nil { + return err + } + if tags != nil { + tags, err := EnsureTagsExist(sess, tags) + if err != nil { + return err + } + for _, tag := range tags { + if _, err := sess.Exec("INSERT INTO alert_rule_tag (alert_id, tag_id) VALUES(?,?)", alert.Id, tag.Id); err != nil { + return err + } + } + } + } + + return nil +} + +func deleteMissingAlerts(alerts []*models.Alert, existingAlerts []*models.Alert, sess *sqlstore.DBSession, log log.Logger) error { + for _, missingAlert := range alerts { + missing := true + + for _, k := range existingAlerts { + if missingAlert.PanelId == k.PanelId { + missing = false + break + } + } + + if missing { + if err := deleteAlertByIdInternal(missingAlert.Id, "Removed from dashboard", sess, log); err != nil { + // No use trying to delete more, since we're in a transaction and it will be + // rolled back on error. + return err + } + } + } + + return nil +} + +func deleteAlertByIdInternal(alertId int64, reason string, sess *sqlstore.DBSession, log log.Logger) error { + log.Debug("Deleting alert", "id", alertId, "reason", reason) + + if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", alertId); err != nil { + return err + } + + if _, err := sess.Exec("DELETE FROM annotation WHERE alert_id = ?", alertId); err != nil { + return err + } + + if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil { + return err + } + + if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alertId); err != nil { + return err + } + + return nil +} + +func EnsureTagsExist(sess *sqlstore.DBSession, tags []*models.Tag) ([]*models.Tag, error) { + for _, tag := range tags { + var existingTag models.Tag + + // check if it exists + exists, err := sess.Table("tag").Where("`key`=? AND `value`=?", tag.Key, tag.Value).Get(&existingTag) + if err != nil { + return nil, err + } + if exists { + tag.Id = existingTag.Id + } else { + _, err := sess.Table("tag").Insert(tag) + if err != nil { + return nil, err + } + } + } + + return tags, nil +} diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/dashboards/database/database_dashboard_test.go similarity index 84% rename from pkg/services/sqlstore/dashboard_test.go rename to pkg/services/dashboards/database/database_dashboard_test.go index 7c8745d74ee..f02745b4512 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/dashboards/database/database_dashboard_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package sqlstore +package database import ( "context" @@ -14,24 +14,25 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDashboardDataAccess(t *testing.T) { - var sqlStore *SQLStore + var sqlStore *sqlstore.SQLStore var savedFolder, savedDash, savedDash2 *models.Dashboard + var dashboardStore *DashboardStore setup := func() { - sqlStore = InitTestDB(t) - savedFolder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp") - savedDash = insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp") - insertTestDashboard(t, sqlStore, "test dash 45", 1, savedFolder.Id, false, "prod") - savedDash2 = insertTestDashboard(t, sqlStore, "test dash 67", 1, 0, false, "prod") + sqlStore = sqlstore.InitTestDB(t) + dashboardStore = ProvideDashboardStore(sqlStore) + savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") + savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp") + insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.Id, false, "prod") + savedDash2 = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod") insertTestRule(t, sqlStore, savedFolder.OrgId, savedFolder.Uid) } @@ -115,7 +116,7 @@ func TestDashboardDataAccess(t *testing.T) { t.Run("Should be able to delete dashboard", func(t *testing.T) { setup() - dash := insertTestDashboard(t, sqlStore, "delete me", 1, 0, false, "delete this") + dash := insertTestDashboard(t, dashboardStore, "delete me", 1, 0, false, "delete this") err := sqlStore.DeleteDashboard(context.Background(), &models.DeleteDashboardCommand{ Id: dash.Id, @@ -124,29 +125,6 @@ func TestDashboardDataAccess(t *testing.T) { require.NoError(t, err) }) - t.Run("Should retry generation of uid once if it fails.", func(t *testing.T) { - setup() - timesCalled := 0 - generateNewUid = func() string { - timesCalled += 1 - if timesCalled <= 2 { - return savedDash.Uid - } - return util.GenerateShortUID() - } - cmd := models.SaveDashboardCommand{ - OrgId: 1, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "title": "new dash 12334", - "tags": []interface{}{}, - }), - } - _, err := sqlStore.SaveDashboard(cmd) - require.NoError(t, err) - - generateNewUid = util.GenerateShortUID - }) - t.Run("Should be able to create dashboard", func(t *testing.T) { setup() cmd := models.SaveDashboardCommand{ @@ -157,13 +135,14 @@ func TestDashboardDataAccess(t *testing.T) { }), UserId: 100, } - dashboard, err := sqlStore.SaveDashboard(cmd) + dashboard, err := dashboardStore.SaveDashboard(cmd) require.NoError(t, err) require.EqualValues(t, dashboard.CreatedBy, 100) require.False(t, dashboard.Created.IsZero()) require.EqualValues(t, dashboard.UpdatedBy, 100) require.False(t, dashboard.Updated.IsZero()) }) + t.Run("Should be able to update dashboard by id and remove folderId", func(t *testing.T) { setup() cmd := models.SaveDashboardCommand{ @@ -177,7 +156,7 @@ func TestDashboardDataAccess(t *testing.T) { FolderId: 2, UserId: 100, } - dash, err := sqlStore.SaveDashboard(cmd) + dash, err := dashboardStore.SaveDashboard(cmd) require.NoError(t, err) require.EqualValues(t, dash.FolderId, 2) @@ -192,7 +171,7 @@ func TestDashboardDataAccess(t *testing.T) { Overwrite: true, UserId: 100, } - _, err = sqlStore.SaveDashboard(cmd) + _, err = dashboardStore.SaveDashboard(cmd) require.NoError(t, err) query := models.GetDashboardQuery{ @@ -211,7 +190,7 @@ func TestDashboardDataAccess(t *testing.T) { t.Run("Should be able to delete empty folder", func(t *testing.T) { setup() - emptyFolder := insertTestDashboard(t, sqlStore, "2 test dash folder", 1, 0, true, "prod", "webapp") + emptyFolder := insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod", "webapp") deleteCmd := &models.DeleteDashboardCommand{Id: emptyFolder.Id} err := sqlStore.DeleteDashboard(context.Background(), deleteCmd) @@ -242,7 +221,7 @@ func TestDashboardDataAccess(t *testing.T) { require.Equal(t, len(query.Result), 0) - sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { var existingRuleID int64 exists, err := sess.Table("alert_rule").Where("namespace_uid = (SELECT uid FROM dashboard WHERE id = ?)", savedFolder.Id).Cols("id").Get(&existingRuleID) require.NoError(t, err) @@ -258,7 +237,6 @@ func TestDashboardDataAccess(t *testing.T) { }) t.Run("Should return error if no dashboard is found for update when dashboard id is greater than zero", func(t *testing.T) { - setup() cmd := models.SaveDashboardCommand{ OrgId: 1, Overwrite: true, @@ -269,12 +247,11 @@ func TestDashboardDataAccess(t *testing.T) { }), } - _, err := sqlStore.SaveDashboard(cmd) + _, err := dashboardStore.SaveDashboard(cmd) require.Equal(t, err, models.ErrDashboardNotFound) }) t.Run("Should not return error if no dashboard is found for update when dashboard id is zero", func(t *testing.T) { - setup() cmd := models.SaveDashboardCommand{ OrgId: 1, Overwrite: true, @@ -284,7 +261,7 @@ func TestDashboardDataAccess(t *testing.T) { "tags": []interface{}{}, }), } - _, err := sqlStore.SaveDashboard(cmd) + _, err := dashboardStore.SaveDashboard(cmd) require.NoError(t, err) }) @@ -405,7 +382,7 @@ func TestDashboardDataAccess(t *testing.T) { t.Run("Should be able to search for starred dashboards", func(t *testing.T) { setup() - starredDash := insertTestDashboard(t, sqlStore, "starred dash", 1, 0, false) + starredDash := insertTestDashboard(t, dashboardStore, "starred dash", 1, 0, false) err := sqlStore.StarDashboard(context.Background(), &models.StarDashboardCommand{ DashboardId: starredDash.Id, UserId: 10, @@ -431,29 +408,32 @@ func TestDashboardDataAccess(t *testing.T) { } func TestDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) { - sqlStore := InitTestDB(t) + sqlStore := sqlstore.InitTestDB(t) + dashboardStore := ProvideDashboardStore(sqlStore) pluginId := "test-app" - appFolder := insertTestDashboardForPlugin(t, sqlStore, "app-test", 1, 0, true, pluginId) - insertTestDashboardForPlugin(t, sqlStore, "app-dash1", 1, appFolder.Id, false, pluginId) - insertTestDashboardForPlugin(t, sqlStore, "app-dash2", 1, appFolder.Id, false, pluginId) + appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId) + insertTestDashboardForPlugin(t, dashboardStore, "app-dash1", 1, appFolder.Id, false, pluginId) + insertTestDashboardForPlugin(t, dashboardStore, "app-dash2", 1, appFolder.Id, false, pluginId) query := models.GetDashboardsByPluginIdQuery{ PluginId: pluginId, OrgId: 1, } - err := GetDashboardsByPluginId(context.Background(), &query) + err := sqlStore.GetDashboardsByPluginId(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) } func TestDashboard_SortingOptions(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + dashboardStore := ProvideDashboardStore(sqlStore) // insertTestDashboard uses GoConvey's assertions. Workaround. t.Run("test with multiple sorting options", func(t *testing.T) { - sqlStore := InitTestDB(t) - dashB := insertTestDashboard(t, sqlStore, "Beta", 1, 0, false) - dashA := insertTestDashboard(t, sqlStore, "Alfa", 1, 0, false) + sqlStore := sqlstore.InitTestDB(t) + dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) + dashA := insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) assert.NotZero(t, dashA.Id) assert.Less(t, dashB.Id, dashA.Id) q := &search.FindPersistedDashboardsQuery{ @@ -464,35 +444,16 @@ func TestDashboard_SortingOptions(t *testing.T) { searchstore.TitleSorter{Descending: true}, }, } - dashboards, err := sqlStore.findDashboards(context.Background(), q) + dashboards, err := sqlStore.FindDashboards(context.Background(), q) require.NoError(t, err) require.Len(t, dashboards, 2) assert.Equal(t, dashA.Id, dashboards[0].ID) assert.Equal(t, dashB.Id, dashboards[1].ID) }) } -func insertTestDashboard(t *testing.T, sqlStore *SQLStore, title string, orgId int64, - folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { - t.Helper() - cmd := models.SaveDashboardCommand{ - OrgId: orgId, - FolderId: folderId, - IsFolder: isFolder, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": title, - "tags": tags, - }), - } - dash, err := sqlStore.SaveDashboard(cmd) - require.NoError(t, err) - require.NotNil(t, dash) - dash.Data.Set("id", dash.Id) - dash.Data.Set("uid", dash.Uid) - return dash -} -func insertTestRule(t *testing.T, sqlStore *SQLStore, foderOrgID int64, folderUID string) { - sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + +func insertTestRule(t *testing.T, sqlStore *sqlstore.SQLStore, foderOrgID int64, folderUID string) { + sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { type alertQuery struct { RefID string DatasourceUID string @@ -562,26 +523,8 @@ func insertTestRule(t *testing.T, sqlStore *SQLStore, foderOrgID int64, folderUI return err }) } -func insertTestDashboardForPlugin(t *testing.T, sqlStore *SQLStore, title string, orgId int64, - folderId int64, isFolder bool, pluginId string) *models.Dashboard { - t.Helper() - cmd := models.SaveDashboardCommand{ - OrgId: orgId, - FolderId: folderId, - IsFolder: isFolder, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": title, - }), - PluginId: pluginId, - } - dash, err := sqlStore.SaveDashboard(cmd) - require.NoError(t, err) - - return dash -} -func createUser(t *testing.T, sqlStore *SQLStore, name string, role string, isAdmin bool) models.User { +func CreateUser(t *testing.T, sqlStore *sqlstore.SQLStore, name string, role string, isAdmin bool) models.User { t.Helper() setting.AutoAssignOrg = true setting.AutoAssignOrgId = 1 @@ -595,3 +538,59 @@ func createUser(t *testing.T, sqlStore *SQLStore, name string, role string, isAd require.Equal(t, models.RoleType(role), q1.Result[0].Role) return *currentUser } + +func insertTestDashboard(t *testing.T, dashboardStore *DashboardStore, title string, orgId int64, + folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + dash, err := dashboardStore.SaveDashboard(cmd) + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + return dash +} + +func insertTestDashboardForPlugin(t *testing.T, dashboardStore *DashboardStore, title string, orgId int64, + folderId int64, isFolder bool, pluginId string) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + }), + PluginId: pluginId, + } + + dash, err := dashboardStore.SaveDashboard(cmd) + require.NoError(t, err) + + return dash +} + +func updateDashboardAcl(t *testing.T, dashboardStore *DashboardStore, dashboardID int64, + items ...models.DashboardAcl) error { + t.Helper() + + var itemPtrs []*models.DashboardAcl + for _, it := range items { + item := it + item.Created = time.Now() + item.Updated = time.Now() + itemPtrs = append(itemPtrs, &item) + } + + return dashboardStore.UpdateDashboardACL(context.Background(), dashboardID, itemPtrs) +} diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go similarity index 81% rename from pkg/services/sqlstore/dashboard_folder_test.go rename to pkg/services/dashboards/database/database_folder_test.go index 3b7007594f0..4718c41a7fb 100644 --- a/pkg/services/sqlstore/dashboard_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -1,32 +1,34 @@ //go:build integration // +build integration -package sqlstore +package database 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/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/require" ) func TestDashboardFolderDataAccess(t *testing.T) { t.Run("Testing DB", func(t *testing.T) { - var sqlStore *SQLStore + var sqlStore *sqlstore.SQLStore var folder, dashInRoot, childDash *models.Dashboard var currentUser models.User + var dashboardStore *DashboardStore setup := func() { - sqlStore = InitTestDB(t) - folder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp") - dashInRoot = insertTestDashboard(t, sqlStore, "test dash 67", 1, 0, false, "prod", "webapp") - childDash = insertTestDashboard(t, sqlStore, "test dash 23", 1, folder.Id, false, "prod", "webapp") - insertTestDashboard(t, sqlStore, "test dash 45", 1, folder.Id, false, "prod") - currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) + sqlStore = sqlstore.InitTestDB(t) + dashboardStore = ProvideDashboardStore(sqlStore) + folder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") + dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp") + childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, folder.Id, false, "prod", "webapp") + insertTestDashboard(t, dashboardStore, "test dash 45", 1, folder.Id, false, "prod") + currentUser = CreateUser(t, sqlStore, "viewer", "Viewer", false) } t.Run("Given one dashboard folder with two dashboards and one dashboard in the root folder", func(t *testing.T) { @@ -49,7 +51,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { t.Run("and acl is set for dashboard folder", func(t *testing.T) { var otherUser int64 = 999 - err := testHelperUpdateDashboardAcl(t, sqlStore, folder.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder.Id, models.DashboardAcl{ DashboardID: folder.Id, OrgID: 1, UserID: otherUser, @@ -70,7 +72,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("when the user is given permission", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, folder.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder.Id, models.DashboardAcl{ DashboardID: folder.Id, OrgID: 1, UserID: currentUser.Id, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) @@ -111,9 +113,9 @@ func TestDashboardFolderDataAccess(t *testing.T) { t.Run("and acl is set for dashboard child and folder has all permissions removed", func(t *testing.T) { var otherUser int64 = 999 - err := testHelperUpdateDashboardAcl(t, sqlStore, folder.Id) + err := updateDashboardAcl(t, dashboardStore, folder.Id) require.NoError(t, err) - err = testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{ + err = updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{ DashboardID: folder.Id, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) @@ -129,7 +131,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("when the user is given permission to child", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{ DashboardID: childDash.Id, OrgID: 1, UserID: currentUser.Id, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) @@ -167,20 +169,21 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func(t *testing.T) { - var sqlStore *SQLStore + var sqlStore *sqlstore.SQLStore var folder1, folder2, dashInRoot, childDash1, childDash2 *models.Dashboard var currentUser models.User var rootFolderId int64 = 0 setup2 := func() { - sqlStore = InitTestDB(t) - folder1 = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod") - folder2 = insertTestDashboard(t, sqlStore, "2 test dash folder", 1, 0, true, "prod") - dashInRoot = insertTestDashboard(t, sqlStore, "test dash 67", 1, 0, false, "prod") - childDash1 = insertTestDashboard(t, sqlStore, "child dash 1", 1, folder1.Id, false, "prod") - childDash2 = insertTestDashboard(t, sqlStore, "child dash 2", 1, folder2.Id, false, "prod") + sqlStore = sqlstore.InitTestDB(t) + dashboardStore := ProvideDashboardStore(sqlStore) + folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") + folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") + dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod") + childDash1 = insertTestDashboard(t, dashboardStore, "child dash 1", 1, folder1.Id, false, "prod") + childDash2 = insertTestDashboard(t, dashboardStore, "child dash 2", 1, folder2.Id, false, "prod") - currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) + currentUser = CreateUser(t, sqlStore, "viewer", "Viewer", false) } setup2() @@ -205,13 +208,13 @@ func TestDashboardFolderDataAccess(t *testing.T) { t.Run("and acl is set for one dashboard folder", func(t *testing.T) { const otherUser int64 = 999 - err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{ DashboardID: folder1.Id, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) t.Run("and a dashboard is moved from folder without acl to the folder with an acl", func(t *testing.T) { - moveDashboard(t, sqlStore, 1, childDash2.Data, folder1.Id) + moveDashboard(t, dashboardStore, 1, childDash2.Data, folder1.Id) t.Run("should not return folder with acl or its children", func(t *testing.T) { query := &search.FindPersistedDashboardsQuery{ @@ -227,7 +230,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("and a dashboard is moved from folder with acl to the folder without an acl", func(t *testing.T) { setup2() - moveDashboard(t, sqlStore, 1, childDash1.Data, folder2.Id) + moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.Id) t.Run("should return folder without acl and its children", func(t *testing.T) { query := &search.FindPersistedDashboardsQuery{ @@ -246,12 +249,12 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("and a dashboard with an acl is moved to the folder without an acl", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, childDash1.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, childDash1.Id, models.DashboardAcl{ DashboardID: childDash1.Id, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) - moveDashboard(t, sqlStore, 1, childDash1.Data, folder2.Id) + moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.Id) t.Run("should return folder without acl but not the dashboard with acl", func(t *testing.T) { query := &search.FindPersistedDashboardsQuery{ @@ -272,19 +275,20 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("Given two dashboard folders", func(t *testing.T) { - var sqlStore *SQLStore + var sqlStore *sqlstore.SQLStore var folder1, folder2 *models.Dashboard var adminUser, editorUser, viewerUser models.User setup3 := func() { - sqlStore = InitTestDB(t) - folder1 = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod") - folder2 = insertTestDashboard(t, sqlStore, "2 test dash folder", 1, 0, true, "prod") - insertTestDashboard(t, sqlStore, "folder in another org", 2, 0, true, "prod") + sqlStore = sqlstore.InitTestDB(t) + dashboardStore := ProvideDashboardStore(sqlStore) + folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") + folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") + insertTestDashboard(t, dashboardStore, "folder in another org", 2, 0, true, "prod") - adminUser = createUser(t, sqlStore, "admin", "Admin", true) - editorUser = createUser(t, sqlStore, "editor", "Editor", false) - viewerUser = createUser(t, sqlStore, "viewer", "Viewer", false) + adminUser = CreateUser(t, sqlStore, "admin", "Admin", true) + editorUser = CreateUser(t, sqlStore, "editor", "Editor", false) + viewerUser = CreateUser(t, sqlStore, "viewer", "Viewer", false) } setup3() @@ -313,7 +317,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgRole: models.ROLE_ADMIN, } - err := GetDashboardPermissionsForUser(context.Background(), &query) + err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -336,7 +340,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { query := &models.HasAdminPermissionInFoldersQuery{ SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_ADMIN}, } - err := HasAdminPermissionInFolders(context.Background(), query) + err := sqlStore.HasAdminPermissionInFolders(context.Background(), query) require.NoError(t, err) require.True(t, query.Result) }) @@ -366,7 +370,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgRole: models.ROLE_EDITOR, } - err := GetDashboardPermissionsForUser(context.Background(), &query) + err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -377,7 +381,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("Should have write access to one dashboard folder if default role changed to view for one folder", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{ DashboardID: folder1.Id, OrgID: 1, UserID: editorUser.Id, Permission: models.PERMISSION_VIEW, }) require.NoError(t, err) @@ -394,7 +398,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { SignedInUser: &models.SignedInUser{UserId: editorUser.Id, OrgId: 1, OrgRole: models.ROLE_EDITOR}, } err := sqlStore.HasEditPermissionInFolders(context.Background(), query) - require.NoError(t, err) + go require.NoError(t, err) require.True(t, query.Result) }) @@ -402,7 +406,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { query := &models.HasAdminPermissionInFoldersQuery{ SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_EDITOR}, } - err := HasAdminPermissionInFolders(context.Background(), query) + err := sqlStore.HasAdminPermissionInFolders(context.Background(), query) require.NoError(t, err) require.False(t, query.Result) }) @@ -432,7 +436,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgRole: models.ROLE_VIEWER, } - err := GetDashboardPermissionsForUser(context.Background(), &query) + err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -443,7 +447,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) t.Run("Should be able to get one dashboard folder if default role changed to edit for one folder", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{ DashboardID: folder1.Id, OrgID: 1, UserID: viewerUser.Id, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) @@ -462,7 +466,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { SignedInUser: &models.SignedInUser{UserId: viewerUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER}, } err := sqlStore.HasEditPermissionInFolders(context.Background(), query) - require.NoError(t, err) + go require.NoError(t, err) require.False(t, query.Result) }) @@ -470,13 +474,13 @@ func TestDashboardFolderDataAccess(t *testing.T) { query := &models.HasAdminPermissionInFoldersQuery{ SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER}, } - err := HasAdminPermissionInFolders(context.Background(), query) + err := sqlStore.HasAdminPermissionInFolders(context.Background(), query) require.NoError(t, err) require.False(t, query.Result) }) t.Run("and admin permission is given for user with org role viewer in one dashboard folder", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{ DashboardID: folder1.Id, OrgID: 1, UserID: viewerUser.Id, Permission: models.PERMISSION_ADMIN, }) require.NoError(t, err) @@ -486,13 +490,13 @@ func TestDashboardFolderDataAccess(t *testing.T) { SignedInUser: &models.SignedInUser{UserId: viewerUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER}, } err := sqlStore.HasEditPermissionInFolders(context.Background(), query) - require.NoError(t, err) + go require.NoError(t, err) require.True(t, query.Result) }) }) t.Run("and edit permission is given for user with org role viewer in one dashboard folder", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{ DashboardID: folder1.Id, OrgID: 1, UserID: viewerUser.Id, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) @@ -502,7 +506,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { SignedInUser: &models.SignedInUser{UserId: viewerUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER}, } err := sqlStore.HasEditPermissionInFolders(context.Background(), query) - require.NoError(t, err) + go require.NoError(t, err) require.True(t, query.Result) }) }) @@ -512,15 +516,16 @@ 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 sqlStore *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") + sqlStore = sqlstore.InitTestDB(t) + dashboardStore := ProvideDashboardStore(sqlStore) + folder2 = insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") + _ = insertTestDashboard(t, dashboardStore, title, orgId, folder2.Id, false, "prod") + folder1 = insertTestDashboard(t, dashboardStore, title, orgId, 0, true, "prod") t.Run("GetFolderByTitle should find the folder", func(t *testing.T) { - result, err := sqlStore.GetFolderByTitle(orgId, title) + result, err := dashboardStore.GetFolderByTitle(orgId, title) require.NoError(t, err) require.Equal(t, folder1.Id, result.Id) }) @@ -528,7 +533,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { }) } -func moveDashboard(t *testing.T, sqlStore *SQLStore, orgId int64, dashboard *simplejson.Json, +func moveDashboard(t *testing.T, dashboardStore *DashboardStore, orgId int64, dashboard *simplejson.Json, newFolderId int64) *models.Dashboard { t.Helper() @@ -538,7 +543,7 @@ func moveDashboard(t *testing.T, sqlStore *SQLStore, orgId int64, dashboard *sim Dashboard: dashboard, Overwrite: true, } - dash, err := sqlStore.SaveDashboard(cmd) + dash, err := dashboardStore.SaveDashboard(cmd) require.NoError(t, err) return dash diff --git a/pkg/services/dashboards/database/database_mock.go b/pkg/services/dashboards/database/database_mock.go new file mode 100644 index 00000000000..c8b154f970f --- /dev/null +++ b/pkg/services/dashboards/database/database_mock.go @@ -0,0 +1,217 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package database + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + models "github.com/grafana/grafana/pkg/models" +) + +// FakeDashboardStore is an autogenerated mock type for the Store type +type FakeDashboardStore struct { + mock.Mock +} + +// GetFolderByTitle provides a mock function with given fields: orgID, title +func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { + ret := _m.Called(orgID, title) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(int64, string) *models.Dashboard); ok { + r0 = rf(orgID, title) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int64, string) error); ok { + r1 = rf(orgID, title) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetProvisionedDashboardData provides a mock function with given fields: name +func (_m *FakeDashboardStore) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { + ret := _m.Called(name) + + var r0 []*models.DashboardProvisioning + if rf, ok := ret.Get(0).(func(string) []*models.DashboardProvisioning); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.DashboardProvisioning) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetProvisionedDataByDashboardID provides a mock function with given fields: dashboardID +func (_m *FakeDashboardStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { + ret := _m.Called(dashboardID) + + var r0 *models.DashboardProvisioning + if rf, ok := ret.Get(0).(func(int64) *models.DashboardProvisioning); ok { + r0 = rf(dashboardID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.DashboardProvisioning) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int64) error); ok { + r1 = rf(dashboardID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetProvisionedDataByDashboardUID provides a mock function with given fields: orgID, dashboardUID +func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { + ret := _m.Called(orgID, dashboardUID) + + var r0 *models.DashboardProvisioning + if rf, ok := ret.Get(0).(func(int64, string) *models.DashboardProvisioning); ok { + r0 = rf(orgID, dashboardUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.DashboardProvisioning) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int64, string) error); ok { + r1 = rf(orgID, dashboardUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveAlerts provides a mock function with given fields: ctx, dashID, alerts +func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { + ret := _m.Called(ctx, dashID, alerts) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, []*models.Alert) error); ok { + r0 = rf(ctx, dashID, alerts) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SaveDashboard provides a mock function with given fields: cmd +func (_m *FakeDashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) { + ret := _m.Called(cmd) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(models.SaveDashboardCommand) *models.Dashboard); ok { + r0 = rf(cmd) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.SaveDashboardCommand) error); ok { + r1 = rf(cmd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveProvisionedDashboard provides a mock function with given fields: cmd, provisioning +func (_m *FakeDashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + ret := _m.Called(cmd, provisioning) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(models.SaveDashboardCommand, *models.DashboardProvisioning) *models.Dashboard); ok { + r0 = rf(cmd, provisioning) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(models.SaveDashboardCommand, *models.DashboardProvisioning) error); ok { + r1 = rf(cmd, provisioning) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UnprovisionDashboard provides a mock function with given fields: ctx, id +func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateDashboardACL provides a mock function with given fields: ctx, uid, items +func (_m *FakeDashboardStore) UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error { + ret := _m.Called(ctx, uid, items) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, []*models.DashboardAcl) error); ok { + r0 = rf(ctx, uid, items) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ValidateDashboardBeforeSave provides a mock function with given fields: dashboard, overwrite +func (_m *FakeDashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) { + ret := _m.Called(dashboard, overwrite) + + var r0 bool + if rf, ok := ret.Get(0).(func(*models.Dashboard, bool) bool); ok { + r0 = rf(dashboard, overwrite) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*models.Dashboard, bool) error); ok { + r1 = rf(dashboard, overwrite) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go similarity index 79% rename from pkg/services/sqlstore/dashboard_provisioning_test.go rename to pkg/services/dashboards/database/database_provisioning_test.go index f74800d8851..bec37866108 100644 --- a/pkg/services/sqlstore/dashboard_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -1,10 +1,11 @@ //go:build integration // +build integration -package sqlstore +package database import ( "context" + "github.com/grafana/grafana/pkg/services/sqlstore" "testing" "time" @@ -15,7 +16,8 @@ import ( ) func TestDashboardProvisioningTest(t *testing.T) { - sqlStore := InitTestDB(t) + sqlStore := sqlstore.InitTestDB(t) + dashboardStore := ProvideDashboardStore(sqlStore) folderCmd := models.SaveDashboardCommand{ OrgId: 1, @@ -27,7 +29,7 @@ func TestDashboardProvisioningTest(t *testing.T) { }), } - dash, err := sqlStore.SaveDashboard(folderCmd) + dash, err := dashboardStore.SaveDashboard(folderCmd) require.Nil(t, err) saveDashboardCmd := models.SaveDashboardCommand{ @@ -49,7 +51,7 @@ func TestDashboardProvisioningTest(t *testing.T) { Updated: now.Unix(), } - dash, err := sqlStore.SaveProvisionedDashboard(saveDashboardCmd, provisioning) + dash, err := dashboardStore.SaveProvisionedDashboard(saveDashboardCmd, provisioning) require.Nil(t, err) require.NotNil(t, dash) require.NotEqual(t, 0, dash.Id) @@ -71,7 +73,7 @@ func TestDashboardProvisioningTest(t *testing.T) { Updated: now.Unix(), } - anotherDash, err := sqlStore.SaveProvisionedDashboard(saveCmd, provisioning) + anotherDash, err := dashboardStore.SaveProvisionedDashboard(saveCmd, provisioning) require.Nil(t, err) query := &models.GetDashboardsQuery{DashboardIds: []int64{anotherDash.Id}} @@ -91,7 +93,7 @@ func TestDashboardProvisioningTest(t *testing.T) { }) t.Run("Can query for provisioned dashboards", func(t *testing.T) { - rslt, err := sqlStore.GetProvisionedDashboardData("default") + rslt, err := dashboardStore.GetProvisionedDashboardData("default") require.Nil(t, err) require.Equal(t, 1, len(rslt)) @@ -100,13 +102,13 @@ func TestDashboardProvisioningTest(t *testing.T) { }) t.Run("Can query for one provisioned dashboard", func(t *testing.T) { - data, err := sqlStore.GetProvisionedDataByDashboardID(dash.Id) + data, err := dashboardStore.GetProvisionedDataByDashboardID(dash.Id) require.Nil(t, err) require.NotNil(t, data) }) t.Run("Can query for none provisioned dashboard", func(t *testing.T) { - data, err := sqlStore.GetProvisionedDataByDashboardID(3000) + data, err := dashboardStore.GetProvisionedDataByDashboardID(3000) require.Nil(t, err) require.Nil(t, data) }) @@ -119,19 +121,15 @@ func TestDashboardProvisioningTest(t *testing.T) { require.Nil(t, sqlStore.DeleteDashboard(context.Background(), deleteCmd)) - data, err := sqlStore.GetProvisionedDataByDashboardID(dash.Id) + data, err := dashboardStore.GetProvisionedDataByDashboardID(dash.Id) require.Nil(t, err) require.Nil(t, data) }) t.Run("UnprovisionDashboard should delete provisioning metadata", func(t *testing.T) { - unprovisionCmd := &models.UnprovisionDashboardCommand{ - Id: dashId, - } + require.Nil(t, dashboardStore.UnprovisionDashboard(context.Background(), dashId)) - require.Nil(t, UnprovisionDashboard(context.Background(), unprovisionCmd)) - - data, err := sqlStore.GetProvisionedDataByDashboardID(dashId) + data, err := dashboardStore.GetProvisionedDataByDashboardID(dashId) require.Nil(t, err) require.Nil(t, data) }) diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/dashboards/database/permissions/database_acl_test.go similarity index 72% rename from pkg/services/sqlstore/dashboard_acl_test.go rename to pkg/services/dashboards/database/permissions/database_acl_test.go index 2a29fade832..f0d74fc4719 100644 --- a/pkg/services/sqlstore/dashboard_acl_test.go +++ b/pkg/services/dashboards/database/permissions/database_acl_test.go @@ -1,11 +1,16 @@ //go:build integration // +build integration -package sqlstore +package permissions import ( "context" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" "testing" + "time" "github.com/grafana/grafana/pkg/models" @@ -13,20 +18,22 @@ import ( ) func TestDashboardAclDataAccess(t *testing.T) { - var sqlStore *SQLStore + var sqlStore *sqlstore.SQLStore var currentUser models.User var savedFolder, childDash *models.Dashboard + var dashboardStore *database.DashboardStore setup := func(t *testing.T) { - sqlStore = InitTestDB(t) + sqlStore = sqlstore.InitTestDB(t) + dashboardStore = database.ProvideDashboardStore(sqlStore) currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) - savedFolder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp") - childDash = insertTestDashboard(t, sqlStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp") + savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") + childDash = insertTestDashboard(t, dashboardStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp") } t.Run("Dashboard permission with userId and teamId set to 0", func(t *testing.T) { setup(t) - err := testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{ OrgID: 1, DashboardID: savedFolder.Id, Permission: models.PERMISSION_EDIT, @@ -70,7 +77,7 @@ func TestDashboardAclDataAccess(t *testing.T) { t.Run("Folder with removed default permissions returns no acl items", func(t *testing.T) { setup(t) - err := sqlStore.UpdateDashboardACL(context.Background(), savedFolder.Id, nil) + err := dashboardStore.UpdateDashboardACL(context.Background(), savedFolder.Id, nil) require.Nil(t, err) query := models.GetDashboardAclInfoListQuery{DashboardID: childDash.Id, OrgID: 1} @@ -84,7 +91,7 @@ func TestDashboardAclDataAccess(t *testing.T) { t.Run("Given dashboard folder permission", func(t *testing.T) { setup(t) - err := testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{ OrgID: 1, UserID: currentUser.Id, DashboardID: savedFolder.Id, @@ -103,7 +110,7 @@ func TestDashboardAclDataAccess(t *testing.T) { }) t.Run("Given child dashboard permission", func(t *testing.T) { - err := testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{ OrgID: 1, UserID: currentUser.Id, DashboardID: childDash.Id, @@ -128,7 +135,7 @@ func TestDashboardAclDataAccess(t *testing.T) { t.Run("Reading dashboard acl should include default acl for parent folder and the child acl", func(t *testing.T) { setup(t) - err := testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{ OrgID: 1, UserID: currentUser.Id, DashboardID: childDash.Id, @@ -155,7 +162,7 @@ func TestDashboardAclDataAccess(t *testing.T) { t.Run("Add and delete dashboard permission", func(t *testing.T) { setup(t) - err := testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{ + err := updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{ OrgID: 1, UserID: currentUser.Id, DashboardID: savedFolder.Id, @@ -174,7 +181,7 @@ func TestDashboardAclDataAccess(t *testing.T) { require.Equal(t, currentUser.Login, q1.Result[0].UserLogin) require.Equal(t, currentUser.Email, q1.Result[0].UserEmail) - err = testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id) + err = updateDashboardAcl(t, dashboardStore, savedFolder.Id) require.Nil(t, err) q3 := &models.GetDashboardAclInfoListQuery{DashboardID: savedFolder.Id, OrgID: 1} @@ -188,7 +195,7 @@ func TestDashboardAclDataAccess(t *testing.T) { team1, err := sqlStore.CreateTeam("group1 name", "", 1) require.Nil(t, err) - err = testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{ + err = updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{ OrgID: 1, TeamID: team1.Id, DashboardID: savedFolder.Id, @@ -208,7 +215,7 @@ func TestDashboardAclDataAccess(t *testing.T) { setup(t) team1, err := sqlStore.CreateTeam("group1 name", "", 1) require.Nil(t, err) - err = testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{ + err = updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{ OrgID: 1, TeamID: team1.Id, DashboardID: savedFolder.Id, @@ -229,7 +236,7 @@ func TestDashboardAclDataAccess(t *testing.T) { t.Run("Default permissions for root folder dashboards", func(t *testing.T) { setup(t) var rootFolderId int64 = 0 - sqlStore := InitTestDB(t) + sqlStore := sqlstore.InitTestDB(t) query := models.GetDashboardAclInfoListQuery{DashboardID: rootFolderId, OrgID: 1} @@ -246,3 +253,54 @@ func TestDashboardAclDataAccess(t *testing.T) { require.False(t, query.Result[1].Inherited) }) } + +func createUser(t *testing.T, sqlStore *sqlstore.SQLStore, name string, role string, isAdmin bool) models.User { + t.Helper() + setting.AutoAssignOrg = true + setting.AutoAssignOrgId = 1 + setting.AutoAssignOrgRole = role + currentUserCmd := models.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin} + currentUser, err := sqlStore.CreateUser(context.Background(), currentUserCmd) + require.NoError(t, err) + q1 := models.GetUserOrgListQuery{UserId: currentUser.Id} + err = sqlStore.GetUserOrgList(context.Background(), &q1) + require.NoError(t, err) + require.Equal(t, models.RoleType(role), q1.Result[0].Role) + return *currentUser +} + +func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, title string, orgId int64, + folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + dash, err := dashboardStore.SaveDashboard(cmd) + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + return dash +} + +func updateDashboardAcl(t *testing.T, dashboardStore *database.DashboardStore, dashboardID int64, + items ...models.DashboardAcl) error { + t.Helper() + + var itemPtrs []*models.DashboardAcl + for _, it := range items { + item := it + item.Created = time.Now() + item.Updated = time.Now() + itemPtrs = append(itemPtrs, &item) + } + + return dashboardStore.UpdateDashboardACL(context.Background(), dashboardID, itemPtrs) +} diff --git a/pkg/services/dashboards/folder.go b/pkg/services/dashboards/folder.go new file mode 100644 index 00000000000..42660db3f76 --- /dev/null +++ b/pkg/services/dashboards/folder.go @@ -0,0 +1,20 @@ +package dashboards + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +//go:generate mockery --name FolderService --structname FakeFolderService --inpackage --filename folder_service_mock.go +// FolderService is a service for operating on folders. +type FolderService interface { + GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) + GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) + GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) + GetFolderByTitle(ctx context.Context, user *models.SignedInUser, orgID int64, title string) (*models.Folder, error) + CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) + UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error + DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) + MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error +} diff --git a/pkg/services/dashboards/folder_service_mock.go b/pkg/services/dashboards/folder_service_mock.go new file mode 100644 index 00000000000..3fcc72601b2 --- /dev/null +++ b/pkg/services/dashboards/folder_service_mock.go @@ -0,0 +1,181 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package dashboards + +import ( + context "context" + + models "github.com/grafana/grafana/pkg/models" + mock "github.com/stretchr/testify/mock" +) + +// FakeFolderService is an autogenerated mock type for the FolderService type +type FakeFolderService struct { + mock.Mock +} + +// CreateFolder provides a mock function with given fields: ctx, user, orgID, title, uid +func (_m *FakeFolderService) CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title string, uid string) (*models.Folder, error) { + ret := _m.Called(ctx, user, orgID, title, uid) + + var r0 *models.Folder + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string, string) *models.Folder); ok { + r0 = rf(ctx, user, orgID, title, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string, string) error); ok { + r1 = rf(ctx, user, orgID, title, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteFolder provides a mock function with given fields: ctx, user, orgID, uid, forceDeleteRules +func (_m *FakeFolderService) DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) { + ret := _m.Called(ctx, user, orgID, uid, forceDeleteRules) + + var r0 *models.Folder + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string, bool) *models.Folder); ok { + r0 = rf(ctx, user, orgID, uid, forceDeleteRules) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string, bool) error); ok { + r1 = rf(ctx, user, orgID, uid, forceDeleteRules) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetFolderByID provides a mock function with given fields: ctx, user, id, orgID +func (_m *FakeFolderService) GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) { + ret := _m.Called(ctx, user, id, orgID) + + var r0 *models.Folder + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, int64) *models.Folder); ok { + r0 = rf(ctx, user, id, orgID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, int64) error); ok { + r1 = rf(ctx, user, id, orgID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetFolderByTitle provides a mock function with given fields: ctx, user, orgID, title +func (_m *FakeFolderService) GetFolderByTitle(ctx context.Context, user *models.SignedInUser, orgID int64, title string) (*models.Folder, error) { + ret := _m.Called(ctx, user, orgID, title) + + var r0 *models.Folder + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string) *models.Folder); ok { + r0 = rf(ctx, user, orgID, title) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string) error); ok { + r1 = rf(ctx, user, orgID, title) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetFolderByUID provides a mock function with given fields: ctx, user, orgID, uid +func (_m *FakeFolderService) GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) { + ret := _m.Called(ctx, user, orgID, uid) + + var r0 *models.Folder + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string) *models.Folder); ok { + r0 = rf(ctx, user, orgID, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string) error); ok { + r1 = rf(ctx, user, orgID, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetFolders provides a mock function with given fields: ctx, user, orgID, limit, page +func (_m *FakeFolderService) GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) { + ret := _m.Called(ctx, user, orgID, limit, page) + + var r0 []*models.Folder + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, int64, int64) []*models.Folder); ok { + r0 = rf(ctx, user, orgID, limit, page) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, int64, int64) error); ok { + r1 = rf(ctx, user, orgID, limit, page) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MakeUserAdmin provides a mock function with given fields: ctx, orgID, userID, folderID, setViewAndEditPermissions +func (_m *FakeFolderService) MakeUserAdmin(ctx context.Context, orgID int64, userID int64, folderID int64, setViewAndEditPermissions bool) error { + ret := _m.Called(ctx, orgID, userID, folderID, setViewAndEditPermissions) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, int64, int64, bool) error); ok { + r0 = rf(ctx, orgID, userID, folderID, setViewAndEditPermissions) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateFolder provides a mock function with given fields: ctx, user, orgID, existingUid, cmd +func (_m *FakeFolderService) UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { + ret := _m.Called(ctx, user, orgID, existingUid, cmd) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string, *models.UpdateFolderCommand) error); ok { + r0 = rf(ctx, user, orgID, existingUid, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/manager/dashboard_service.go similarity index 60% rename from pkg/services/dashboards/dashboard_service.go rename to pkg/services/dashboards/manager/dashboard_service.go index 1c0035d8737..759582dfcdd 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/manager/dashboard_service.go @@ -1,4 +1,4 @@ -package dashboards +package service import ( "context" @@ -7,86 +7,42 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" - "github.com/grafana/grafana/pkg/dashboards" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + m "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" ) -// DashboardService is a service for operating on dashboards. -type DashboardService interface { - SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) - ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) - DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error - MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error +type DashboardServiceImpl struct { + dashboardStore m.Store + log log.Logger } -// DashboardProvisioningService is a service for operating on provisioned dashboards. -type DashboardProvisioningService interface { - SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) - SaveFolderForProvisionedDashboards(context.Context, *SaveDashboardDTO) (*models.Dashboard, error) - GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) - GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) - GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) - UnprovisionDashboard(ctx context.Context, dashboardID int64) error - DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error -} - -// NewService is a factory for creating a new dashboard service. -var NewService = func(store dashboards.Store) DashboardService { - return &dashboardServiceImpl{ +func ProvideDashboardService(store m.Store) *DashboardServiceImpl { + return &DashboardServiceImpl{ dashboardStore: store, log: log.New("dashboard-service"), } } -// NewProvisioningService is a factory for creating a new dashboard provisioning service. -var NewProvisioningService = func(store dashboards.Store) DashboardProvisioningService { - return NewService(store).(*dashboardServiceImpl) -} - -type SaveDashboardDTO struct { - OrgId int64 - UpdatedAt time.Time - User *models.SignedInUser - Message string - Overwrite bool - Dashboard *models.Dashboard -} - -type dashboardServiceImpl struct { - dashboardStore dashboards.Store - orgId int64 - user *models.SignedInUser - log log.Logger -} - -func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { +func (dr *DashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { return dr.dashboardStore.GetProvisionedDashboardData(name) } -// GetProvisionedData gets provisioned dashboard data. -// -// Stubbable by tests. -var GetProvisionedData = func(store dashboards.Store, dashboardID int64) (*models.DashboardProvisioning, error) { - return store.GetProvisionedDataByDashboardID(dashboardID) +func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { + return dr.dashboardStore.GetProvisionedDataByDashboardID(dashboardID) } -func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { - return GetProvisionedData(dr.dashboardStore, dashboardID) -} - -func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { +func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { return dr.dashboardStore.GetProvisionedDataByDashboardUID(orgID, dashboardUID) } -func (dr *dashboardServiceImpl) buildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, +func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, dto *m.SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) { dash := dto.Dashboard @@ -175,6 +131,10 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(ctx context.Context, d return cmd, nil } +func (dr *DashboardServiceImpl) UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error { + return dr.dashboardStore.UpdateDashboardACL(ctx, uid, items) +} + var validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { extractor := alerting.NewDashAlertExtractor(dash, dash.OrgId, user) return extractor.ValidateAlerts(ctx) @@ -210,7 +170,7 @@ func validateDashboardRefreshInterval(dash *models.Dashboard) error { // UpdateAlerting updates alerting. // // Stubbable by tests. -var UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error { +var UpdateAlerting = func(ctx context.Context, store m.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error { extractor := alerting.NewDashAlertExtractor(dashboard, orgID, user) alerts, err := extractor.GetAlerts(ctx) if err != nil { @@ -220,7 +180,7 @@ var UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int return store.SaveAlerts(ctx, dashboard.Id, alerts) } -func (dr *dashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, +func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *m.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { dr.log.Warn("Changing refresh interval for provisioned dashboard to minimum refresh interval", "dashboardUid", @@ -234,7 +194,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt OrgId: dto.OrgId, } - cmd, err := dr.buildSaveDashboardCommand(ctx, dto, true, false) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true, false) if err != nil { return nil, err } @@ -253,12 +213,12 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt return dash, nil } -func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) { +func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *m.SaveDashboardDTO) (*models.Dashboard, error) { dto.User = &models.SignedInUser{ UserId: 0, OrgRole: models.ROLE_ADMIN, } - cmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, false) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false, false) if err != nil { return nil, err } @@ -275,7 +235,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C return dash, nil } -func (dr *dashboardServiceImpl) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, +func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *m.SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) { if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval", @@ -284,7 +244,7 @@ func (dr *dashboardServiceImpl) SaveDashboard(ctx context.Context, dto *SaveDash dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval) } - cmd, err := dr.buildSaveDashboardCommand(ctx, dto, true, !allowUiUpdate) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true, !allowUiUpdate) if err != nil { return nil, err } @@ -303,16 +263,59 @@ func (dr *dashboardServiceImpl) SaveDashboard(ctx context.Context, dto *SaveDash // DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for // operations by the user where we want to make sure user does not delete provisioned dashboard. -func (dr *dashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { +func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { return dr.deleteDashboard(ctx, dashboardId, orgId, true) } +func (dr *DashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, userID int64, dashboardID int64, setViewAndEditPermissions bool) error { + rtEditor := models.ROLE_EDITOR + rtViewer := models.ROLE_VIEWER + + items := []*models.DashboardAcl{ + { + OrgID: orgID, + DashboardID: dashboardID, + UserID: userID, + Permission: models.PERMISSION_ADMIN, + Created: time.Now(), + Updated: time.Now(), + }, + } + + if setViewAndEditPermissions { + items = append(items, + &models.DashboardAcl{ + OrgID: orgID, + DashboardID: dashboardID, + Role: &rtEditor, + Permission: models.PERMISSION_EDIT, + Created: time.Now(), + Updated: time.Now(), + }, + &models.DashboardAcl{ + OrgID: orgID, + DashboardID: dashboardID, + Role: &rtViewer, + Permission: models.PERMISSION_VIEW, + Created: time.Now(), + Updated: time.Now(), + }, + ) + } + + if err := dr.dashboardStore.UpdateDashboardACL(ctx, dashboardID, items); err != nil { + return err + } + + return nil +} + // DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned. -func (dr *dashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error { +func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error { return dr.deleteDashboard(ctx, dashboardId, orgId, false) } -func (dr *dashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, orgId int64, validateProvisionedDashboard bool) error { +func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, orgId int64, validateProvisionedDashboard bool) error { if validateProvisionedDashboard { provisionedData, err := dr.GetProvisionedDashboardDataByDashboardID(dashboardId) if err != nil { @@ -327,7 +330,7 @@ func (dr *dashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId return bus.Dispatch(ctx, cmd) } -func (dr *dashboardServiceImpl) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) ( +func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *m.SaveDashboardDTO) ( *models.Dashboard, error) { if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval", @@ -336,7 +339,7 @@ func (dr *dashboardServiceImpl) ImportDashboard(ctx context.Context, dto *SaveDa dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval) } - cmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, true) + cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false, true) if err != nil { return nil, err } @@ -351,50 +354,6 @@ func (dr *dashboardServiceImpl) ImportDashboard(ctx context.Context, dto *SaveDa // UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed // and provisioned dashboards are left behind but not deleted. -func (dr *dashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error { - cmd := &models.UnprovisionDashboardCommand{Id: dashboardId} - return bus.Dispatch(ctx, cmd) -} - -type FakeDashboardService struct { - DashboardService - - SaveDashboardResult *models.Dashboard - SaveDashboardError error - SavedDashboards []*SaveDashboardDTO - ProvisionedDashData *models.DashboardProvisioning -} - -func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) { - s.SavedDashboards = append(s.SavedDashboards, dto) - - if s.SaveDashboardResult == nil && s.SaveDashboardError == nil { - s.SaveDashboardResult = dto.Dashboard - } - - return s.SaveDashboardResult, s.SaveDashboardError -} - -func (s *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) { - return s.SaveDashboard(ctx, dto, true) -} - -func (s *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { - for index, dash := range s.SavedDashboards { - if dash.Dashboard.Id == dashboardId && dash.OrgId == orgId { - s.SavedDashboards = append(s.SavedDashboards[:index], s.SavedDashboards[index+1:]...) - break - } - } - return nil -} - -func (s *FakeDashboardService) GetProvisionedDashboardDataByDashboardID(id int64) (*models.DashboardProvisioning, error) { - return s.ProvisionedDashData, nil -} - -func MockDashboardService(mock *FakeDashboardService) { - NewService = func(dashboards.Store) DashboardService { - return mock - } +func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error { + return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) } diff --git a/pkg/services/dashboards/dashboard_service_integration_test.go b/pkg/services/dashboards/manager/dashboard_service_integration_test.go similarity index 96% rename from pkg/services/dashboards/dashboard_service_integration_test.go rename to pkg/services/dashboards/manager/dashboard_service_integration_test.go index 8f6fba2d402..00bc7ca305b 100644 --- a/pkg/services/dashboards/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/manager/dashboard_service_integration_test.go @@ -1,20 +1,20 @@ //go:build integration // +build integration -package dashboards +package service import ( "context" "testing" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/dashboards" + "github.com/grafana/grafana/pkg/models" + dashbboardservice "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/models" ) const testOrgID int64 = 1 @@ -25,7 +25,7 @@ func TestIntegratedDashboardService(t *testing.T) { t.Cleanup(func() { UpdateAlerting = origUpdateAlerting }) - UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error { + UpdateAlerting = func(ctx context.Context, store dashbboardservice.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error { return nil } @@ -860,7 +860,8 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore t.Helper() dto := toSaveDashboardDto(cmd) - res, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false) + dashboardStore := database.ProvideDashboardStore(sqlStore) + res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) return res @@ -868,7 +869,8 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore *sqlstore.SQLStore) error { dto := toSaveDashboardDto(cmd) - _, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false) + dashboardStore := database.ProvideDashboardStore(sqlStore) + _, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) return err } @@ -885,7 +887,7 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto }), } - dto := SaveDashboardDTO{ + dto := dashbboardservice.SaveDashboardDTO{ OrgId: orgID, Dashboard: cmd.GetDashboardModel(), User: &models.SignedInUser{ @@ -894,7 +896,8 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto }, } - res, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false) + dashboardStore := database.ProvideDashboardStore(sqlStore) + res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) return res @@ -912,7 +915,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore *sqlstore. }), } - dto := SaveDashboardDTO{ + dto := dashbboardservice.SaveDashboardDTO{ OrgId: orgID, Dashboard: cmd.GetDashboardModel(), User: &models.SignedInUser{ @@ -921,16 +924,17 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore *sqlstore. }, } - res, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false) + dashboardStore := database.ProvideDashboardStore(sqlStore) + res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false) require.NoError(t, err) return res } -func toSaveDashboardDto(cmd models.SaveDashboardCommand) SaveDashboardDTO { +func toSaveDashboardDto(cmd models.SaveDashboardCommand) dashbboardservice.SaveDashboardDTO { dash := (&cmd).GetDashboardModel() - return SaveDashboardDTO{ + return dashbboardservice.SaveDashboardDTO{ Dashboard: dash, Message: cmd.Message, OrgId: cmd.OrgId, diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/manager/dashboard_service_test.go similarity index 59% rename from pkg/services/dashboards/dashboard_service_test.go rename to pkg/services/dashboards/manager/dashboard_service_test.go index ec92ab0f6b7..1fb721e2e9f 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/manager/dashboard_service_test.go @@ -1,18 +1,22 @@ -package dashboards +//go:build integration +// +build integration + +package service import ( "context" - "fmt" + "errors" "testing" - "github.com/grafana/grafana/pkg/dashboards" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + m "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/guardian" - + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -20,8 +24,9 @@ func TestDashboardService(t *testing.T) { t.Run("Dashboard service tests", func(t *testing.T) { bus.ClearBusHandlers() - fakeStore := fakeDashboardStore{} - service := &dashboardServiceImpl{ + fakeStore := database.FakeDashboardStore{} + defer fakeStore.AssertExpectations(t) + service := &DashboardServiceImpl{ log: log.New("test.logger"), dashboardStore: &fakeStore, } @@ -31,7 +36,7 @@ func TestDashboardService(t *testing.T) { guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) t.Run("Save dashboard validation", func(t *testing.T) { - dto := &SaveDashboardDTO{} + dto := &m.SaveDashboardDTO{} t.Run("When saving a dashboard with empty title it should return error", func(t *testing.T) { titles := []string{"", " ", " \t "} @@ -57,14 +62,6 @@ func TestDashboardService(t *testing.T) { }) t.Run("When saving a dashboard should validate uid", func(t *testing.T) { - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return nil - } - testCases := []struct { Uid string Error error @@ -83,24 +80,17 @@ func TestDashboardService(t *testing.T) { dto.Dashboard.SetUid(tc.Uid) dto.User = &models.SignedInUser{} - _, err := service.buildSaveDashboardCommand(context.Background(), dto, true, false) + if tc.Error == nil { + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + } + _, err := service.BuildSaveDashboardCommand(context.Background(), dto, true, false) require.Equal(t, err, tc.Error) } }) t.Run("Should return validation error if dashboard is provisioned", func(t *testing.T) { - t.Cleanup(func() { - fakeStore.provisionedData = nil - }) - fakeStore.provisionedData = &models.DashboardProvisioning{} - - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return nil - } + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{}, nil).Once() dto.Dashboard = models.NewDashboard("Dash") dto.Dashboard.SetId(3) @@ -110,13 +100,9 @@ func TestDashboardService(t *testing.T) { }) t.Run("Should not return validation error if dashboard is provisioned but UI updates allowed", func(t *testing.T) { - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return nil - } + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + fakeStore.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once() + fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() dto.Dashboard = models.NewDashboard("Dash") dto.Dashboard.SetId(3) @@ -126,40 +112,25 @@ func TestDashboardService(t *testing.T) { }) t.Run("Should return validation error if alert data is invalid", func(t *testing.T) { - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return fmt.Errorf("alert validation error") - } + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(nil, nil).Once() + fakeStore.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once() + fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("alert validation error")).Once() dto.Dashboard = models.NewDashboard("Dash") + dto.User = &models.SignedInUser{UserId: 1} _, err := service.SaveDashboard(context.Background(), dto, false) require.Equal(t, err.Error(), "alert validation error") }) }) t.Run("Save provisioned dashboard validation", func(t *testing.T) { - dto := &SaveDashboardDTO{} + dto := &m.SaveDashboardDTO{} t.Run("Should not return validation error if dashboard is provisioned", func(t *testing.T) { - origUpdateAlerting := UpdateAlerting - t.Cleanup(func() { - UpdateAlerting = origUpdateAlerting - }) - UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, - user *models.SignedInUser) error { - return nil - } - - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return nil - } + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once() + fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() dto.Dashboard = models.NewDashboard("Dash") dto.Dashboard.SetId(3) @@ -169,27 +140,14 @@ func TestDashboardService(t *testing.T) { }) t.Run("Should override invalid refresh interval if dashboard is provisioned", func(t *testing.T) { + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once() + fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + oldRefreshInterval := setting.MinRefreshInterval setting.MinRefreshInterval = "5m" defer func() { setting.MinRefreshInterval = oldRefreshInterval }() - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return nil - } - - origUpdateAlerting := UpdateAlerting - t.Cleanup(func() { - UpdateAlerting = origUpdateAlerting - }) - UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, - user *models.SignedInUser) error { - return nil - } - dto.Dashboard = models.NewDashboard("Dash") dto.Dashboard.SetId(3) dto.User = &models.SignedInUser{UserId: 1} @@ -201,30 +159,11 @@ func TestDashboardService(t *testing.T) { }) t.Run("Import dashboard validation", func(t *testing.T) { - dto := &SaveDashboardDTO{} + dto := &m.SaveDashboardDTO{} t.Run("Should return validation error if dashboard is provisioned", func(t *testing.T) { - t.Cleanup(func() { - fakeStore.provisionedData = nil - }) - fakeStore.provisionedData = &models.DashboardProvisioning{} - - origValidateAlerts := validateAlerts - t.Cleanup(func() { - validateAlerts = origValidateAlerts - }) - validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error { - return nil - } - - origUpdateAlerting := UpdateAlerting - t.Cleanup(func() { - UpdateAlerting = origUpdateAlerting - }) - UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, - user *models.SignedInUser) error { - return nil - } + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once() + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{}, nil).Once() dto.Dashboard = models.NewDashboard("Dash") dto.Dashboard.SetId(3) @@ -236,14 +175,15 @@ func TestDashboardService(t *testing.T) { t.Run("Given provisioned dashboard", func(t *testing.T) { t.Run("DeleteProvisionedDashboard should delete it", func(t *testing.T) { - result := setupDeleteHandlers(t, &fakeStore, true) + result := setupDeleteHandlers(t) err := service.DeleteProvisionedDashboard(context.Background(), 1, 1) require.NoError(t, err) require.True(t, result.deleteWasCalled) }) t.Run("DeleteDashboard should fail to delete it", func(t *testing.T) { - result := setupDeleteHandlers(t, &fakeStore, true) + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{}, nil).Once() + result := setupDeleteHandlers(t) err := service.DeleteDashboard(context.Background(), 1, 1) require.Equal(t, err, models.ErrDashboardCannotDeleteProvisionedDashboard) require.False(t, result.deleteWasCalled) @@ -251,7 +191,7 @@ func TestDashboardService(t *testing.T) { }) t.Run("Given non provisioned dashboard", func(t *testing.T) { - result := setupDeleteHandlers(t, &fakeStore, false) + result := setupDeleteHandlers(t) t.Run("DeleteProvisionedDashboard should delete it", func(t *testing.T) { err := service.DeleteProvisionedDashboard(context.Background(), 1, 1) @@ -260,6 +200,7 @@ func TestDashboardService(t *testing.T) { }) t.Run("DeleteDashboard should delete it", func(t *testing.T) { + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(nil, nil).Once() err := service.DeleteDashboard(context.Background(), 1, 1) require.NoError(t, err) require.True(t, result.deleteWasCalled) @@ -272,16 +213,9 @@ type Result struct { deleteWasCalled bool } -func setupDeleteHandlers(t *testing.T, fakeStore *fakeDashboardStore, provisioned bool) *Result { +func setupDeleteHandlers(t *testing.T) *Result { t.Helper() - t.Cleanup(func() { - fakeStore.provisionedData = nil - }) - if provisioned { - fakeStore.provisionedData = &models.DashboardProvisioning{} - } - result := &Result{} bus.AddHandler("test", func(ctx context.Context, cmd *models.DeleteDashboardCommand) error { require.Equal(t, cmd.Id, int64(1)) @@ -292,32 +226,3 @@ func setupDeleteHandlers(t *testing.T, fakeStore *fakeDashboardStore, provisione return result } - -type fakeDashboardStore struct { - dashboards.Store - - validationError error - provisionedData *models.DashboardProvisioning -} - -func (s *fakeDashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) ( - bool, error) { - return false, s.validationError -} - -func (s *fakeDashboardStore) GetProvisionedDataByDashboardID(int64) (*models.DashboardProvisioning, error) { - return s.provisionedData, nil -} - -func (s *fakeDashboardStore) SaveProvisionedDashboard(models.SaveDashboardCommand, - *models.DashboardProvisioning) (*models.Dashboard, error) { - return nil, nil -} - -func (s *fakeDashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) { - return cmd.GetDashboardModel(), nil -} - -func (s *fakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { - return nil -} diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/manager/folder_service.go similarity index 55% rename from pkg/services/dashboards/folder_service.go rename to pkg/services/dashboards/manager/folder_service.go index b6f05b1e661..2876496bb12 100644 --- a/pkg/services/dashboards/folder_service.go +++ b/pkg/services/dashboards/manager/folder_service.go @@ -1,4 +1,4 @@ -package dashboards +package service import ( "context" @@ -6,46 +6,42 @@ import ( "strings" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/dashboards" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/search" ) -// FolderService is a service for operating on folders. -type FolderService interface { - GetFolders(ctx context.Context, limit int64, page int64) ([]*models.Folder, error) - GetFolderByID(ctx context.Context, id int64) (*models.Folder, error) - GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error) - GetFolderByTitle(ctx context.Context, title string) (*models.Folder, error) - CreateFolder(ctx context.Context, title, uid string) (*models.Folder, error) - UpdateFolder(ctx context.Context, uid string, cmd *models.UpdateFolderCommand) error - DeleteFolder(ctx context.Context, uid string, forceDeleteRules bool) (*models.Folder, error) - MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error +type FolderServiceImpl struct { + dashboardService dashboards.DashboardService + dashboardStore dashboards.Store + searchService *search.SearchService + log log.Logger } -// NewFolderService is a factory for creating a new folder service. -var NewFolderService = func(orgID int64, user *models.SignedInUser, store dashboards.Store) FolderService { - return &dashboardServiceImpl{ - orgId: orgID, - user: user, - dashboardStore: store, +func ProvideFolderService(dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, searchService *search.SearchService) *FolderServiceImpl { + return &FolderServiceImpl{ + dashboardService: dashboardService, + dashboardStore: dashboardStore, + searchService: searchService, + log: log.New("folder-service"), } } -func (dr *dashboardServiceImpl) GetFolders(ctx context.Context, limit int64, page int64) ([]*models.Folder, error) { +func (f *FolderServiceImpl) GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) { searchQuery := search.Query{ - SignedInUser: dr.user, + SignedInUser: user, DashboardIds: make([]int64, 0), FolderIds: make([]int64, 0), Limit: limit, - OrgId: dr.orgId, + OrgId: orgID, Type: "dash-folder", Permission: models.PERMISSION_VIEW, Page: page, } - if err := bus.Dispatch(ctx, &searchQuery); err != nil { + if err := f.searchService.SearchHandler(ctx, &searchQuery); err != nil { return nil, err } @@ -62,17 +58,17 @@ func (dr *dashboardServiceImpl) GetFolders(ctx context.Context, limit int64, pag return folders, nil } -func (dr *dashboardServiceImpl) GetFolderByID(ctx context.Context, id int64) (*models.Folder, error) { +func (f *FolderServiceImpl) GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) { if id == 0 { return &models.Folder{Id: id, Title: "General"}, nil } - query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id} + query := models.GetDashboardQuery{OrgId: orgID, Id: id} dashFolder, err := getFolder(ctx, query) if err != nil { return nil, toFolderError(err) } - g := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user) + g := guardian.New(ctx, dashFolder.Id, orgID, user) if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) @@ -83,15 +79,15 @@ func (dr *dashboardServiceImpl) GetFolderByID(ctx context.Context, id int64) (*m return dashToFolder(dashFolder), nil } -func (dr *dashboardServiceImpl) GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error) { - query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} +func (f *FolderServiceImpl) GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: orgID, Uid: uid} dashFolder, err := getFolder(ctx, query) if err != nil { return nil, toFolderError(err) } - g := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user) + g := guardian.New(ctx, dashFolder.Id, orgID, user) if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) @@ -102,13 +98,13 @@ func (dr *dashboardServiceImpl) GetFolderByUID(ctx context.Context, uid string) return dashToFolder(dashFolder), nil } -func (dr *dashboardServiceImpl) GetFolderByTitle(ctx context.Context, title string) (*models.Folder, error) { - dashFolder, err := dr.dashboardStore.GetFolderByTitle(dr.orgId, title) +func (f *FolderServiceImpl) GetFolderByTitle(ctx context.Context, user *models.SignedInUser, orgID int64, title string) (*models.Folder, error) { + dashFolder, err := f.dashboardStore.GetFolderByTitle(orgID, title) if err != nil { return nil, toFolderError(err) } - g := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user) + g := guardian.New(ctx, dashFolder.Id, orgID, user) if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) @@ -119,11 +115,11 @@ func (dr *dashboardServiceImpl) GetFolderByTitle(ctx context.Context, title stri return dashToFolder(dashFolder), nil } -func (dr *dashboardServiceImpl) CreateFolder(ctx context.Context, title, uid string) (*models.Folder, error) { +func (f *FolderServiceImpl) CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) { dashFolder := models.NewDashboardFolder(title) - dashFolder.OrgId = dr.orgId + dashFolder.OrgId = orgID dashFolder.SetUid(strings.TrimSpace(uid)) - userID := dr.user.UserId + userID := user.UserId if userID == 0 { userID = -1 } @@ -131,23 +127,23 @@ func (dr *dashboardServiceImpl) CreateFolder(ctx context.Context, title, uid str dashFolder.UpdatedBy = userID dashFolder.UpdateSlug() - dto := &SaveDashboardDTO{ + dto := &dashboards.SaveDashboardDTO{ Dashboard: dashFolder, - OrgId: dr.orgId, - User: dr.user, + OrgId: orgID, + User: user, } - saveDashboardCmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, false) + saveDashboardCmd, err := f.dashboardService.BuildSaveDashboardCommand(ctx, dto, false, false) if err != nil { return nil, toFolderError(err) } - dash, err := dr.dashboardStore.SaveDashboard(*saveDashboardCmd) + dash, err := f.dashboardStore.SaveDashboard(*saveDashboardCmd) if err != nil { return nil, toFolderError(err) } - query := models.GetDashboardQuery{OrgId: dr.orgId, Id: dash.Id} + query := models.GetDashboardQuery{OrgId: orgID, Id: dash.Id} dashFolder, err = getFolder(ctx, query) if err != nil { return nil, toFolderError(err) @@ -156,33 +152,33 @@ func (dr *dashboardServiceImpl) CreateFolder(ctx context.Context, title, uid str return dashToFolder(dashFolder), nil } -func (dr *dashboardServiceImpl) UpdateFolder(ctx context.Context, existingUid string, cmd *models.UpdateFolderCommand) error { - query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid} +func (f *FolderServiceImpl) UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { + query := models.GetDashboardQuery{OrgId: orgID, Uid: existingUid} dashFolder, err := getFolder(ctx, query) if err != nil { return toFolderError(err) } - cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId) + cmd.UpdateDashboardModel(dashFolder, orgID, user.UserId) - dto := &SaveDashboardDTO{ + dto := &dashboards.SaveDashboardDTO{ Dashboard: dashFolder, - OrgId: dr.orgId, - User: dr.user, + OrgId: orgID, + User: user, Overwrite: cmd.Overwrite, } - saveDashboardCmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, false) + saveDashboardCmd, err := f.dashboardService.BuildSaveDashboardCommand(ctx, dto, false, false) if err != nil { return toFolderError(err) } - dash, err := dr.dashboardStore.SaveDashboard(*saveDashboardCmd) + dash, err := f.dashboardStore.SaveDashboard(*saveDashboardCmd) if err != nil { return toFolderError(err) } - query = models.GetDashboardQuery{OrgId: dr.orgId, Id: dash.Id} + query = models.GetDashboardQuery{OrgId: orgID, Id: dash.Id} dashFolder, err = getFolder(ctx, query) if err != nil { return toFolderError(err) @@ -193,14 +189,14 @@ func (dr *dashboardServiceImpl) UpdateFolder(ctx context.Context, existingUid st return nil } -func (dr *dashboardServiceImpl) DeleteFolder(ctx context.Context, uid string, forceDeleteRules bool) (*models.Folder, error) { - query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid} +func (f *FolderServiceImpl) DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) { + query := models.GetDashboardQuery{OrgId: orgID, Uid: uid} dashFolder, err := getFolder(ctx, query) if err != nil { return nil, toFolderError(err) } - guardian := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user) + guardian := guardian.New(ctx, dashFolder.Id, orgID, user) if canSave, err := guardian.CanSave(); err != nil || !canSave { if err != nil { return nil, toFolderError(err) @@ -208,7 +204,7 @@ func (dr *dashboardServiceImpl) DeleteFolder(ctx context.Context, uid string, fo return nil, models.ErrFolderAccessDenied } - deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id, ForceDeleteFolderRules: forceDeleteRules} + deleteCmd := models.DeleteDashboardCommand{OrgId: orgID, Id: dashFolder.Id, ForceDeleteFolderRules: forceDeleteRules} if err := bus.Dispatch(ctx, &deleteCmd); err != nil { return nil, toFolderError(err) } @@ -216,6 +212,10 @@ func (dr *dashboardServiceImpl) DeleteFolder(ctx context.Context, uid string, fo return dashToFolder(dashFolder), nil } +func (f *FolderServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error { + return f.dashboardService.MakeUserAdmin(ctx, orgID, userID, folderID, setViewAndEditPermissions) +} + func getFolder(ctx context.Context, query models.GetDashboardQuery) (*models.Dashboard, error) { if err := bus.Dispatch(ctx, &query); err != nil { return nil, toFolderError(err) diff --git a/pkg/services/dashboards/folder_service_test.go b/pkg/services/dashboards/manager/folder_service_test.go similarity index 73% rename from pkg/services/dashboards/folder_service_test.go rename to pkg/services/dashboards/manager/folder_service_test.go index 7d08b6a6bf7..962e0fe88de 100644 --- a/pkg/services/dashboards/folder_service_test.go +++ b/pkg/services/dashboards/manager/folder_service_test.go @@ -1,25 +1,34 @@ -package dashboards +//go:build integration +// +build integration + +package service import ( "context" "testing" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) +var orgID = int64(1) +var user = &models.SignedInUser{UserId: 1} + func TestFolderService(t *testing.T) { t.Run("Folder service tests", func(t *testing.T) { - service := dashboardServiceImpl{ - orgId: 1, - user: &models.SignedInUser{UserId: 1}, - dashboardStore: &fakeDashboardStore{}, - } + store := &database.FakeDashboardStore{} + defer store.AssertExpectations(t) + service := ProvideFolderService( + &dashboards.FakeDashboardService{DashboardService: ProvideDashboardService(store)}, + store, + nil, + ) t.Run("Given user has no permissions", func(t *testing.T) { origNewGuardian := guardian.New @@ -30,37 +39,30 @@ func TestFolderService(t *testing.T) { return nil }) - origStore := service.dashboardStore - t.Cleanup(func() { - service.dashboardStore = origStore - }) - service.dashboardStore = &fakeDashboardStore{ - validationError: models.ErrDashboardUpdateAccessDenied, - } - t.Run("When get folder by id should return access denied error", func(t *testing.T) { - _, err := service.GetFolderByID(context.Background(), 1) + _, err := service.GetFolderByID(context.Background(), user, 1, orgID) require.Equal(t, err, models.ErrFolderAccessDenied) }) t.Run("When get folder by id, with id = 0 should return default folder", func(t *testing.T) { - folder, err := service.GetFolderByID(context.Background(), 0) + folder, err := service.GetFolderByID(context.Background(), user, 0, orgID) require.NoError(t, err) require.Equal(t, folder, &models.Folder{Id: 0, Title: "General"}) }) t.Run("When get folder by uid should return access denied error", func(t *testing.T) { - _, err := service.GetFolderByUID(context.Background(), "uid") + _, err := service.GetFolderByUID(context.Background(), user, orgID, "uid") require.Equal(t, err, models.ErrFolderAccessDenied) }) t.Run("When creating folder should return access denied error", func(t *testing.T) { - _, err := service.CreateFolder(context.Background(), "Folder", "") + store.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Times(2) + _, err := service.CreateFolder(context.Background(), user, orgID, "Folder", "") require.Equal(t, err, models.ErrFolderAccessDenied) }) t.Run("When updating folder should return access denied error", func(t *testing.T) { - err := service.UpdateFolder(context.Background(), "uid", &models.UpdateFolderCommand{ + err := service.UpdateFolder(context.Background(), user, orgID, "uid", &models.UpdateFolderCommand{ Uid: "uid", Title: "Folder", }) @@ -68,7 +70,7 @@ func TestFolderService(t *testing.T) { }) t.Run("When deleting folder by uid should return access denied error", func(t *testing.T) { - _, err := service.DeleteFolder(context.Background(), "uid", false) + _, err := service.DeleteFolder(context.Background(), user, orgID, "uid", false) require.Error(t, err) require.Equal(t, err, models.ErrFolderAccessDenied) }) @@ -90,15 +92,6 @@ func TestFolderService(t *testing.T) { return nil }) - origUpdateAlerting := UpdateAlerting - t.Cleanup(func() { - UpdateAlerting = origUpdateAlerting - }) - UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, - user *models.SignedInUser) error { - return nil - } - bus.AddHandler("test", func(ctx context.Context, cmd *models.SaveDashboardCommand) error { cmd.Result = dash return nil @@ -109,12 +102,15 @@ func TestFolderService(t *testing.T) { }) t.Run("When creating folder should not return access denied error", func(t *testing.T) { - _, err := service.CreateFolder(context.Background(), "Folder", "") + store.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Times(2) + store.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once() + _, err := service.CreateFolder(context.Background(), user, orgID, "Folder", "") require.NoError(t, err) }) t.Run("When updating folder should not return access denied error", func(t *testing.T) { - err := service.UpdateFolder(context.Background(), "uid", &models.UpdateFolderCommand{ + store.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once() + err := service.UpdateFolder(context.Background(), user, orgID, "uid", &models.UpdateFolderCommand{ Uid: "uid", Title: "Folder", }) @@ -122,7 +118,7 @@ func TestFolderService(t *testing.T) { }) t.Run("When deleting folder by uid should not return access denied error", func(t *testing.T) { - _, err := service.DeleteFolder(context.Background(), "uid", false) + _, err := service.DeleteFolder(context.Background(), user, orgID, "uid", false) require.NoError(t, err) }) @@ -145,14 +141,14 @@ func TestFolderService(t *testing.T) { }) t.Run("When get folder by id should return folder", func(t *testing.T) { - f, _ := service.GetFolderByID(context.Background(), 1) + f, _ := service.GetFolderByID(context.Background(), user, orgID, 1) require.Equal(t, f.Id, dashFolder.Id) require.Equal(t, f.Uid, dashFolder.Uid) require.Equal(t, f.Title, dashFolder.Title) }) t.Run("When get folder by uid should return folder", func(t *testing.T) { - f, _ := service.GetFolderByUID(context.Background(), "uid") + f, _ := service.GetFolderByUID(context.Background(), user, orgID, "uid") require.Equal(t, f.Id, dashFolder.Id) require.Equal(t, f.Uid, dashFolder.Uid) require.Equal(t, f.Title, dashFolder.Title) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go new file mode 100644 index 00000000000..eea9e9c9b44 --- /dev/null +++ b/pkg/services/dashboards/models.go @@ -0,0 +1,16 @@ +package dashboards + +import ( + "time" + + "github.com/grafana/grafana/pkg/models" +) + +type SaveDashboardDTO struct { + OrgId int64 + UpdatedAt time.Time + User *models.SignedInUser + Message string + Overwrite bool + Dashboard *models.Dashboard +} diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go index 48dfb5ab2f1..990e60bb818 100644 --- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go +++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go @@ -12,11 +12,11 @@ import ( type Service struct { Bus bus.Bus - SQLStore *sqlstore.SQLStore + SQLStore sqlstore.Store SecretsService secrets.Service } -func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service) *Service { +func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Service) *Service { s := &Service{ Bus: bus, SQLStore: store, diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go index f25fb98ebe4..9f5e1cd273b 100644 --- a/pkg/services/datasources/service/datasource_service.go +++ b/pkg/services/datasources/service/datasource_service.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" @@ -23,9 +24,11 @@ import ( ) type Service struct { - Bus bus.Bus - SQLStore *sqlstore.SQLStore - SecretsService secrets.Service + Bus bus.Bus + SQLStore *sqlstore.SQLStore + SecretsService secrets.Service + features featuremgmt.FeatureToggles + permissionsService accesscontrol.PermissionsService ptc proxyTransportCache dsDecryptionCache secureJSONDecryptionCache @@ -51,7 +54,10 @@ type cachedDecryptedJSON struct { json map[string]string } -func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, ac accesscontrol.AccessControl) *Service { +func ProvideService( + bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, features featuremgmt.FeatureToggles, + ac accesscontrol.AccessControl, permissionsServices accesscontrol.PermissionsServices, +) *Service { s := &Service{ Bus: bus, SQLStore: store, @@ -62,6 +68,8 @@ func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secret dsDecryptionCache: secureJSONDecryptionCache{ cache: make(map[int64]cachedDecryptedJSON), }, + features: features, + permissionsService: permissionsServices.GetDataSourceService(), } s.Bus.AddHandler(s.GetDataSources) @@ -128,7 +136,23 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *models.AddDataSourceCo return err } - return s.SQLStore.AddDataSource(ctx, cmd) + if err := s.SQLStore.AddDataSource(ctx, cmd); err != nil { + return err + } + + if s.features.IsEnabled(featuremgmt.FlagAccesscontrol) { + if _, err := s.permissionsService.SetPermissions(ctx, cmd.OrgId, strconv.FormatInt(cmd.Result.Id, 10), accesscontrol.SetResourcePermissionCommand{ + BuiltinRole: "Viewer", + Permission: "Query", + }, accesscontrol.SetResourcePermissionCommand{ + BuiltinRole: "Editor", + Permission: "Query", + }); err != nil { + return err + } + } + + return nil } func (s *Service) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { diff --git a/pkg/services/datasources/service/datasource_service_test.go b/pkg/services/datasources/service/datasource_service_test.go index 99ddcbf8c8a..ef9159dbd5d 100644 --- a/pkg/services/datasources/service/datasource_service_test.go +++ b/pkg/services/datasources/service/datasource_service_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -36,7 +37,7 @@ func TestService(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - s := ProvideService(bus.New(), sqlStore, secretsService, &acmock.Mock{}) + s := ProvideService(bus.New(), sqlStore, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) var ds *models.DataSource @@ -151,7 +152,7 @@ func TestService_GetHttpTransport(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) rt1, err := dsService.GetHTTPTransport(&ds, provider) require.NoError(t, err) @@ -184,7 +185,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuthWithCACert", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope()) require.NoError(t, err) @@ -234,7 +235,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuth", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) tlsClientCert, err := secretsService.Encrypt(context.Background(), []byte(clientCert), secrets.WithoutScope()) require.NoError(t, err) @@ -277,7 +278,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("serverName", "server-name") secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope()) require.NoError(t, err) @@ -314,7 +315,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsSkipVerify", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -345,7 +346,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) encryptedData, err := secretsService.Encrypt(context.Background(), []byte(`Bearer xf5yhfkpsnmgo`), secrets.WithoutScope()) require.NoError(t, err) @@ -404,7 +405,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -437,7 +438,7 @@ func TestService_GetHttpTransport(t *testing.T) { require.NoError(t, err) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Type: models.DS_ES, @@ -471,7 +472,7 @@ func TestService_getTimeout(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) for _, tc := range testCases { ds := &models.DataSource{ @@ -484,7 +485,7 @@ func TestService_getTimeout(t *testing.T) { func TestService_DecryptedValue(t *testing.T) { t.Run("When datasource hasn't been updated, encrypted JSON should be fetched from cache", func(t *testing.T) { secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) encryptedJsonData, err := secretsService.EncryptJsonData( context.Background(), @@ -538,7 +539,7 @@ func TestService_DecryptedValue(t *testing.T) { SecureJsonData: encryptedJsonData, } - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) // Populate cache password, ok := dsService.DecryptedValue(&ds, "password") @@ -574,7 +575,7 @@ func TestService_HTTPClientOptions(t *testing.T) { t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -592,7 +593,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -611,7 +612,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) _, err := dsService.httpClientOptions(&ds) assert.Error(t, err) @@ -625,7 +626,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bd715f9457a..d95d6b385c1 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -38,6 +38,11 @@ var ( Description: "Schedule automatic updates to dashboard previews", State: FeatureStateAlpha, }, + { + Name: "dashboardPreviewsAdmin", + Description: "Manage the dashboard previews crawler process from the UI", + State: FeatureStateAlpha, + }, { Name: "live-config", Description: "Save grafana live configuration in SQL tables", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 960a7b0f511..7c54af1412e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -31,6 +31,10 @@ const ( // Schedule automatic updates to dashboard previews FlagDashboardPreviewsScheduler = "dashboardPreviewsScheduler" + // FlagDashboardPreviewsAdmin + // Manage the dashboard previews crawler process from the UI + FlagDashboardPreviewsAdmin = "dashboardPreviewsAdmin" + // FlagLiveConfig // Save grafana live configuration in SQL tables FlagLiveConfig = "live-config" diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go index 8d483488140..7265e3acc3d 100644 --- a/pkg/services/libraryelements/guard.go +++ b/pkg/services/libraryelements/guard.go @@ -4,7 +4,6 @@ import ( "context" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" ) @@ -32,9 +31,7 @@ func (l *LibraryElementService) requirePermissionsOnFolder(ctx context.Context, if isGeneralFolder(folderID) && user.HasRole(models.ROLE_VIEWER) { return models.ErrFolderAccessDenied } - - s := dashboards.NewFolderService(user.OrgId, user, l.SQLStore) - folder, err := s.GetFolderByID(ctx, folderID) + folder, err := l.folderService.GetFolderByID(ctx, user, folderID, user.OrgId) if err != nil { return err } diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go index 302b31a1b59..50229a71b68 100644 --- a/pkg/services/libraryelements/libraryelements.go +++ b/pkg/services/libraryelements/libraryelements.go @@ -6,15 +6,17 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister) *LibraryElementService { +func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister, folderService dashboards.FolderService) *LibraryElementService { l := &LibraryElementService{ Cfg: cfg, SQLStore: sqlStore, RouteRegister: routeRegister, + folderService: folderService, log: log.New("library-elements"), } l.registerAPIEndpoints() @@ -36,6 +38,7 @@ type LibraryElementService struct { Cfg *setting.Cfg SQLStore *sqlstore.SQLStore RouteRegister routing.RouteRegister + folderService dashboards.FolderService log log.Logger } diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 5744f9c8b5e..8c1df027581 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -9,14 +9,13 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/components/simplejson" - - dboards "github.com/grafana/grafana/pkg/dashboards" - "github.com/google/go-cmp/cmp" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -194,16 +193,9 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user models.Sign User: &user, Overwrite: false, } - origUpdateAlerting := dashboards.UpdateAlerting - t.Cleanup(func() { - dashboards.UpdateAlerting = origUpdateAlerting - }) - dashboards.UpdateAlerting = func(ctx context.Context, store dboards.Store, orgID int64, dashboard *models.Dashboard, - user *models.SignedInUser) error { - return nil - } - dashboard, err := dashboards.NewService(sqlStore).SaveDashboard(context.Background(), dashItem, true) + dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboard, err := dashboardservice.ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), dashItem, true) require.NoError(t, err) return dashboard @@ -213,17 +205,19 @@ func createFolderWithACL(t *testing.T, sqlStore *sqlstore.SQLStore, title string items []folderACLItem) *models.Folder { t.Helper() - s := dashboards.NewFolderService(user.OrgId, &user, sqlStore) + dashboardStore := database.ProvideDashboardStore(sqlStore) + d := dashboardservice.ProvideDashboardService(dashboardStore) + s := dashboardservice.ProvideFolderService(d, dashboardStore, nil) t.Logf("Creating folder with title and UID %q", title) - folder, err := s.CreateFolder(context.Background(), title, title) + folder, err := s.CreateFolder(context.Background(), &user, user.OrgId, title, title) require.NoError(t, err) - updateFolderACL(t, sqlStore, folder.Id, items) + updateFolderACL(t, dashboardStore, folder.Id, items) return folder } -func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64, items []folderACLItem) { +func updateFolderACL(t *testing.T, dashboardStore *database.DashboardStore, folderID int64, items []folderACLItem) { t.Helper() if len(items) == 0 { @@ -243,7 +237,7 @@ func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64, }) } - err := sqlStore.UpdateDashboardACL(context.Background(), folderID, aclItems) + err := dashboardStore.UpdateDashboardACL(context.Background(), folderID, aclItems) require.NoError(t, err) } @@ -297,9 +291,12 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := models.ROLE_ADMIN sqlStore := sqlstore.InitTestDB(t) + dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboardService := dashboardservice.ProvideDashboardService(dashboardStore) service := LibraryElementService{ - Cfg: setting.NewCfg(), - SQLStore: sqlStore, + Cfg: setting.NewCfg(), + SQLStore: sqlStore, + folderService: dashboardservice.ProvideFolderService(dashboardService, dashboardStore, nil), } user := models.SignedInUser{ diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 2a73e01e632..7a101269709 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -8,15 +8,16 @@ import ( "time" "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" - dboards "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) const userInDbName = "user_in_db" @@ -1413,16 +1414,9 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user *models.Sig User: user, Overwrite: false, } - origUpdateAlerting := dashboards.UpdateAlerting - t.Cleanup(func() { - dashboards.UpdateAlerting = origUpdateAlerting - }) - dashboards.UpdateAlerting = func(ctx context.Context, store dboards.Store, orgID int64, dashboard *models.Dashboard, - user *models.SignedInUser) error { - return nil - } - dashboard, err := dashboards.NewService(sqlStore).SaveDashboard(context.Background(), dashItem, true) + dashboadStore := database.ProvideDashboardStore(sqlStore) + dashboard, err := dashboardservice.ProvideDashboardService(dashboadStore).SaveDashboard(context.Background(), dashItem, true) require.NoError(t, err) return dashboard @@ -1432,17 +1426,19 @@ func createFolderWithACL(t *testing.T, sqlStore *sqlstore.SQLStore, title string items []folderACLItem) *models.Folder { t.Helper() - s := dashboards.NewFolderService(user.OrgId, user, sqlStore) + dashboardStore := database.ProvideDashboardStore(sqlStore) + d := dashboardservice.ProvideDashboardService(dashboardStore) + s := dashboardservice.ProvideFolderService(d, dashboardStore, nil) t.Logf("Creating folder with title and UID %q", title) - folder, err := s.CreateFolder(context.Background(), title, title) + folder, err := s.CreateFolder(context.Background(), user, user.OrgId, title, title) require.NoError(t, err) - updateFolderACL(t, sqlStore, folder.Id, items) + updateFolderACL(t, dashboardStore, folder.Id, items) return folder } -func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64, items []folderACLItem) { +func updateFolderACL(t *testing.T, dashboardStore *database.DashboardStore, folderID int64, items []folderACLItem) { t.Helper() if len(items) == 0 { @@ -1462,7 +1458,7 @@ func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64, }) } - err := sqlStore.UpdateDashboardACL(context.Background(), folderID, aclItems) + err := dashboardStore.UpdateDashboardACL(context.Background(), folderID, aclItems) require.NoError(t, err) } @@ -1519,14 +1515,14 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := models.ROLE_ADMIN sqlStore := sqlstore.InitTestDB(t) - elementService := libraryelements.LibraryElementService{ - Cfg: cfg, - SQLStore: sqlStore, - } + dashboardStore := database.ProvideDashboardStore(sqlStore) + folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore), dashboardStore, nil) + + elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService) service := LibraryPanelService{ Cfg: cfg, SQLStore: sqlStore, - LibraryElementService: &elementService, + LibraryElementService: elementService, } user := &models.SignedInUser{ @@ -1555,7 +1551,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo user: user, ctx: context.Background(), service: &service, - elementService: &elementService, + elementService: elementService, sqlStore: sqlStore, } diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 53162c1fa13..9dd8be60457 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -5,12 +5,11 @@ import ( "net/url" "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" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/api" @@ -25,11 +24,12 @@ 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" ) func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService, routeRegister routing.RouteRegister, sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService, - quotaService *quota.QuotaService, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert) (*AlertNG, error) { + quotaService *quota.QuotaService, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert, folderService dashboards.FolderService) (*AlertNG, error) { ng := &AlertNG{ Cfg: cfg, DataSourceCache: dataSourceCache, @@ -41,8 +41,9 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService, QuotaService: quotaService, SecretsService: secretsService, Metrics: m, - NotificationService: notificationService, Log: log.New("ngalert"), + NotificationService: notificationService, + folderService: folderService, } if ng.IsDisabled() { @@ -72,6 +73,7 @@ type AlertNG struct { Log log.Logger schedule schedule.ScheduleService stateManager *state.Manager + folderService dashboards.FolderService // Alerting notification services MultiOrgAlertmanager *notifier.MultiOrgAlertmanager @@ -82,9 +84,10 @@ func (ng *AlertNG) init() error { store := &store.DBstore{ BaseInterval: ng.Cfg.UnifiedAlerting.BaseInterval, - DefaultInterval: ng.Cfg.UnifiedAlerting.DefaultAlertForDuration, + DefaultInterval: ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval, SQLStore: ng.SQLStore, Logger: ng.Log, + FolderService: ng.folderService, } decryptFn := ng.SecretsService.GetDecryptedValue diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index a0a16ec7871..dead82108a1 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -12,8 +12,6 @@ import ( "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -395,12 +393,11 @@ func (st DBstore) GetRuleGroupAlertRules(ctx context.Context, query *ngmodels.Li // GetNamespaces returns the folders that are visible to the user func (st DBstore) GetNamespaces(ctx context.Context, orgID int64, user *models.SignedInUser) (map[string]*models.Folder, error) { - s := dashboards.NewFolderService(orgID, user, st.SQLStore) namespaceMap := make(map[string]*models.Folder) var page int64 = 1 for { // if limit is negative; it fetches at most 1000 - folders, err := s.GetFolders(ctx, -1, page) + folders, err := st.FolderService.GetFolders(ctx, user, orgID, -1, page) if err != nil { return nil, err } @@ -419,8 +416,7 @@ func (st DBstore) GetNamespaces(ctx context.Context, orgID int64, user *models.S // GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces. func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user *models.SignedInUser, withCanSave bool) (*models.Folder, error) { - s := dashboards.NewFolderService(orgID, user, st.SQLStore) - folder, err := s.GetFolderByTitle(ctx, namespace) + folder, err := st.FolderService.GetFolderByTitle(ctx, user, orgID, namespace) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index 7a6505704e7..aa0e601d632 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/sqlstore" ) @@ -31,4 +32,5 @@ type DBstore struct { DefaultInterval time.Duration SQLStore *sqlstore.SQLStore Logger log.Logger + FolderService dashboards.FolderService } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index b017436111e..b0af3a96e34 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -9,6 +9,8 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/log" + databasestore "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/services/ngalert" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -40,9 +42,11 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, * m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := sqlstore.InitTestDB(t) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + dashboardStore := databasestore.ProvideDashboardStore(sqlStore) + folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore), dashboardStore, nil) ng, err := ngalert.ProvideService( cfg, nil, routing.NewRouteRegister(), sqlStore, - nil, nil, nil, nil, secretsService, nil, m, + nil, nil, nil, nil, secretsService, nil, m, folderService, ) require.NoError(t, err) return ng, &store.DBstore{ diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index f5d0a4dc08e..660d33fa8bb 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -6,9 +6,9 @@ import ( "os" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -23,7 +23,7 @@ type DashboardProvisioner interface { } // DashboardProvisionerFactory creates DashboardProvisioners based on input -type DashboardProvisionerFactory func(context.Context, string, dashboards.Store) (DashboardProvisioner, error) +type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService) (DashboardProvisioner, error) // Provisioner is responsible for syncing dashboard from disk to Grafana's database. type Provisioner struct { @@ -34,7 +34,7 @@ type Provisioner struct { } // New returns a new DashboardProvisioner -func New(ctx context.Context, configDirectory string, store dashboards.Store) (DashboardProvisioner, error) { +func New(ctx context.Context, configDirectory string, service dashboards.DashboardProvisioningService) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") cfgReader := &configReader{path: configDirectory, log: logger} configs, err := cfgReader.readConfig(ctx) @@ -42,7 +42,7 @@ func New(ctx context.Context, configDirectory string, store dashboards.Store) (D return nil, errutil.Wrap("Failed to read dashboards config", err) } - fileReaders, err := getFileReaders(configs, logger, store) + fileReaders, err := getFileReaders(configs, logger, service) if err != nil { return nil, errutil.Wrap("Failed to initialize file readers", err) } @@ -120,14 +120,13 @@ func (provider *Provisioner) GetAllowUIUpdatesFromConfig(name string) bool { return false } -func getFileReaders(configs []*config, logger log.Logger, store dashboards.Store) ([]*FileReader, error) { +func getFileReaders(configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService) ([]*FileReader, error) { var readers []*FileReader for _, config := range configs { switch config.Type { case "file": - fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), - store) + fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service) if err != nil { return nil, errutil.Wrapf(err, "Failed to create file reader for config %v", config.Name) } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 9f1bedf2f81..2b84b13dc62 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" - dboards "github.com/grafana/grafana/pkg/dashboards" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" @@ -41,7 +40,7 @@ type FileReader struct { } // NewDashboardFileReader returns a new filereader based on `config` -func NewDashboardFileReader(cfg *config, log log.Logger, store dboards.Store) (*FileReader, error) { +func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService) (*FileReader, error) { var path string path, ok := cfg.Options["path"].(string) if !ok { @@ -62,7 +61,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, store dboards.Store) (* Cfg: cfg, Path: path, log: log, - dashboardProvisioningService: dashboards.NewProvisioningService(store), + dashboardProvisioningService: service, FoldersFromFilesStructure: foldersFromFilesStructure, usageTracker: newUsageTracker(), }, nil diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 70c7ad3cdae..810b3a8d284 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -2,8 +2,6 @@ package dashboards import ( "context" - "fmt" - "math/rand" "os" "path/filepath" "runtime" @@ -11,13 +9,12 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" - dboards "github.com/grafana/grafana/pkg/dashboards" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/util" - - "github.com/grafana/grafana/pkg/infra/log" - + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -28,10 +25,9 @@ const ( containingID = "testdata/test-dashboards/containing-id" unprovision = "testdata/test-dashboards/unprovision" foldersFromFilesStructure = "testdata/test-dashboards/folders-from-files-structure" + configName = "default" ) -var fakeService *fakeDashboardProvisioningService - func TestCreatingNewDashboardFileReader(t *testing.T) { setup := func() *config { return &config{ @@ -98,17 +94,14 @@ func TestDashboardFileReader(t *testing.T) { logger := log.New("test.logger") cfg := &config{} - origNewDashboardProvisioningService := dashboards.NewProvisioningService - defer func() { - dashboards.NewProvisioningService = origNewDashboardProvisioningService - }() + fakeService := &dashboards.FakeDashboardProvisioning{} + defer fakeService.AssertExpectations(t) setup := func() { bus.ClearBusHandlers() - fakeService = mockDashboardProvisioningService() bus.AddHandler("test", mockGetDashboardQuery) cfg = &config{ - Name: "Default", + Name: configName, Type: "file", OrgID: 1, Folder: "", @@ -122,45 +115,38 @@ func TestDashboardFileReader(t *testing.T) { cfg.Options["path"] = defaultDashboards cfg.Folder = "Team A" + fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once() + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 2}, nil).Times(2) + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - - folders := 0 - dashboards := 0 - - for _, i := range fakeService.inserted { - if i.Dashboard.IsFolder { - folders++ - } else { - dashboards++ - } - } - - require.Equal(t, folders, 1) - require.Equal(t, dashboards, 2) }) t.Run("Can read default dashboard and replace old version in database", func(t *testing.T) { setup() cfg.Options["path"] = oneDashboard - stat, _ := os.Stat(oneDashboard + "/dashboard1.json") - - fakeService.getDashboard = append(fakeService.getDashboard, &models.Dashboard{ - Updated: stat.ModTime().AddDate(0, 0, -1), - Slug: "grafana", - }) + inserted := 0 + fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything). + Return(&models.Dashboard{}, nil).Once(). + Run(func(args mock.Arguments) { + inserted++ + }) reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - require.Equal(t, len(fakeService.inserted), 1) + assert.Equal(t, inserted, 1) }) t.Run("Dashboard with older timestamp and the same checksum will not replace imported dashboard", func(t *testing.T) { @@ -179,23 +165,23 @@ func TestDashboardFileReader(t *testing.T) { checksum, err := util.Md5Sum(file) require.NoError(t, err) - fakeService.provisioned = map[string][]*models.DashboardProvisioning{ - "Default": { - { - Name: "Default", - ExternalId: absPath, - Updated: stat.ModTime().AddDate(0, 0, +1).Unix(), - CheckSum: checksum, - }, + provisionedDashboard := []*models.DashboardProvisioning{ + { + Name: "Default", + ExternalId: absPath, + Updated: stat.ModTime().AddDate(0, 0, +1).Unix(), + CheckSum: checksum, }, } + fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - require.Equal(t, len(fakeService.inserted), 0) }) t.Run("Dashboard with older timestamp and different checksum will replace imported dashboard", func(t *testing.T) { @@ -206,23 +192,24 @@ func TestDashboardFileReader(t *testing.T) { stat, err := os.Stat(oneDashboard + "/dashboard1.json") require.NoError(t, err) - fakeService.provisioned = map[string][]*models.DashboardProvisioning{ - "Default": { - { - Name: "Default", - ExternalId: absPath, - Updated: stat.ModTime().AddDate(0, 0, +1).Unix(), - CheckSum: "fakechecksum", - }, + provisionedDashboard := []*models.DashboardProvisioning{ + { + Name: "Default", + ExternalId: absPath, + Updated: stat.ModTime().AddDate(0, 0, +1).Unix(), + CheckSum: "fakechecksum", }, } + fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - require.Equal(t, len(fakeService.inserted), 1) }) t.Run("Dashboard with newer timestamp and the same checksum will not replace imported dashboard", func(t *testing.T) { @@ -241,23 +228,23 @@ func TestDashboardFileReader(t *testing.T) { checksum, err := util.Md5Sum(file) require.NoError(t, err) - fakeService.provisioned = map[string][]*models.DashboardProvisioning{ - "Default": { - { - Name: "Default", - ExternalId: absPath, - Updated: stat.ModTime().AddDate(0, 0, -1).Unix(), - CheckSum: checksum, - }, + provisionedDashboard := []*models.DashboardProvisioning{ + { + Name: "Default", + ExternalId: absPath, + Updated: stat.ModTime().AddDate(0, 0, -1).Unix(), + CheckSum: checksum, }, } + fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - require.Equal(t, len(fakeService.inserted), 0) }) t.Run("Dashboard with newer timestamp and different checksum should replace imported dashboard", func(t *testing.T) { @@ -268,36 +255,39 @@ func TestDashboardFileReader(t *testing.T) { stat, err := os.Stat(oneDashboard + "/dashboard1.json") require.NoError(t, err) - fakeService.provisioned = map[string][]*models.DashboardProvisioning{ - "Default": { - { - Name: "Default", - ExternalId: absPath, - Updated: stat.ModTime().AddDate(0, 0, -1).Unix(), - CheckSum: "fakechecksum", - }, + provisionedDashboard := []*models.DashboardProvisioning{ + { + Name: "Default", + ExternalId: absPath, + Updated: stat.ModTime().AddDate(0, 0, -1).Unix(), + CheckSum: "fakechecksum", }, } + fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - require.Equal(t, len(fakeService.inserted), 1) }) t.Run("Overrides id from dashboard.json files", func(t *testing.T) { setup() cfg.Options["path"] = containingID + fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - - require.Equal(t, len(fakeService.inserted), 1) }) t.Run("Get folder from files structure", func(t *testing.T) { @@ -305,40 +295,16 @@ func TestDashboardFileReader(t *testing.T) { cfg.Options["path"] = foldersFromFilesStructure cfg.Options["foldersFromFilesStructure"] = true + fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once() + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3) + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - - require.Equal(t, len(fakeService.inserted), 5) - - foldersCount := 0 - for _, d := range fakeService.inserted { - if d.Dashboard.IsFolder { - foldersCount++ - } - } - require.Equal(t, foldersCount, 2) - - foldersAndDashboards := make(map[string]struct{}, 5) - for _, d := range fakeService.inserted { - title := d.Dashboard.Title - if _, ok := foldersAndDashboards[title]; ok { - require.Nil(t, fmt.Errorf("dashboard title %q already exists", title)) - } - - switch title { - case "folderOne", "folderTwo": - require.True(t, d.Dashboard.IsFolder) - case "Grafana1", "Grafana2", "RootDashboard": - require.False(t, d.Dashboard.IsFolder) - default: - require.Nil(t, fmt.Errorf("unknown dashboard title %q", title)) - } - - foldersAndDashboards[title] = struct{}{} - } }) t.Run("Invalid configuration should return error", func(t *testing.T) { @@ -367,30 +333,23 @@ func TestDashboardFileReader(t *testing.T) { cfg1 := &config{Name: "1", Type: "file", OrgID: 1, Folder: "f1", Options: map[string]interface{}{"path": containingID}} cfg2 := &config{Name: "2", Type: "file", OrgID: 1, Folder: "f2", Options: map[string]interface{}{"path": containingID}} + fakeService.On("GetProvisionedDashboardData", mock.Anything).Return(nil, nil).Times(2) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) + reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader1.walkDisk(context.Background()) require.NoError(t, err) reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader2.walkDisk(context.Background()) require.NoError(t, err) - - var folderCount int - var dashCount int - for _, o := range fakeService.inserted { - if o.Dashboard.IsFolder { - folderCount++ - } else { - dashCount++ - } - } - - require.Equal(t, folderCount, 2) - require.Equal(t, dashCount, 2) }) }) @@ -422,16 +381,9 @@ func TestDashboardFileReader(t *testing.T) { }, } - folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() + _, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder) require.NoError(t, err) - inserted := false - for _, d := range fakeService.inserted { - if d.Dashboard.IsFolder && d.Dashboard.Id == folderID { - inserted = true - } - } - require.Equal(t, len(fakeService.inserted), 1) - require.True(t, inserted) }) t.Run("Walking the folder with dashboards", func(t *testing.T) { @@ -456,56 +408,53 @@ func TestDashboardFileReader(t *testing.T) { absPath2, err := filepath.Abs(unprovision + "/dashboard2.json") require.NoError(t, err) + provisionedDashboard := []*models.DashboardProvisioning{ + {DashboardId: 1, Name: "Default", ExternalId: absPath1}, + {DashboardId: 2, Name: "Default", ExternalId: absPath2}, + } + setupFakeService := func() { setup() cfg = &config{ - Name: "Default", + Name: configName, Type: "file", OrgID: 1, Options: map[string]interface{}{ "folder": unprovision, }, } - - fakeService.inserted = []*dashboards.SaveDashboardDTO{ - {Dashboard: &models.Dashboard{Id: 1}}, - {Dashboard: &models.Dashboard{Id: 2}}, - } - - fakeService.provisioned = map[string][]*models.DashboardProvisioning{ - "Default": { - {DashboardId: 1, Name: "Default", ExternalId: absPath1}, - {DashboardId: 2, Name: "Default", ExternalId: absPath2}, - }, - } } t.Run("Missing dashboard should be unprovisioned if DisableDeletion = true", func(t *testing.T) { setupFakeService() + + fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() + fakeService.On("UnprovisionDashboard", mock.Anything, mock.Anything).Return(nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() + cfg.DisableDeletion = true reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - - require.Equal(t, len(fakeService.provisioned["Default"]), 1) - require.Equal(t, fakeService.provisioned["Default"][0].ExternalId, absPath1) }) t.Run("Missing dashboard should be deleted if DisableDeletion = false", func(t *testing.T) { setupFakeService() + + fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once() + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once() + fakeService.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + reader, err := NewDashboardFileReader(cfg, logger, nil) + reader.dashboardProvisioningService = fakeService require.NoError(t, err) err = reader.walkDisk(context.Background()) require.NoError(t, err) - - require.Equal(t, len(fakeService.provisioned["Default"]), 1) - require.Equal(t, fakeService.provisioned["Default"][0].ExternalId, absPath1) - require.Equal(t, len(fakeService.inserted), 1) - require.Equal(t, fakeService.inserted[0].Dashboard.Id, int64(1)) }) }) } @@ -539,111 +488,6 @@ func (ffi FakeFileInfo) Sys() interface{} { return nil } -func mockDashboardProvisioningService() *fakeDashboardProvisioningService { - mock := fakeDashboardProvisioningService{ - provisioned: map[string][]*models.DashboardProvisioning{}, - } - dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService { - return &mock - } - return &mock -} - -type fakeDashboardProvisioningService struct { - dashboards.DashboardProvisioningService - - inserted []*dashboards.SaveDashboardDTO - provisioned map[string][]*models.DashboardProvisioning - getDashboard []*models.Dashboard -} - -func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { - if _, ok := s.provisioned[name]; !ok { - s.provisioned[name] = []*models.DashboardProvisioning{} - } - - return s.provisioned[name], nil -} - -func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO, - provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { - // Copy the structs as we need to change them but do not want to alter outside world. - var copyProvisioning = &models.DashboardProvisioning{} - *copyProvisioning = *provisioning - - var copyDto = &dashboards.SaveDashboardDTO{} - *copyDto = *dto - - if copyDto.Dashboard.Id == 0 { - copyDto.Dashboard.Id = rand.Int63n(1000000) - } else { - err := s.DeleteProvisionedDashboard(context.Background(), dto.Dashboard.Id, dto.Dashboard.OrgId) - // Lets delete existing so we do not have duplicates - if err != nil { - return nil, err - } - } - - s.inserted = append(s.inserted, dto) - - if _, ok := s.provisioned[provisioning.Name]; !ok { - s.provisioned[provisioning.Name] = []*models.DashboardProvisioning{} - } - - for _, val := range s.provisioned[provisioning.Name] { - if val.DashboardId == dto.Dashboard.Id && val.Name == provisioning.Name { - // Do not insert duplicates - return dto.Dashboard, nil - } - } - - copyProvisioning.DashboardId = copyDto.Dashboard.Id - - s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], copyProvisioning) - return dto.Dashboard, nil -} - -func (s *fakeDashboardProvisioningService) SaveFolderForProvisionedDashboards(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { - s.inserted = append(s.inserted, dto) - return dto.Dashboard, nil -} - -func (s *fakeDashboardProvisioningService) UnprovisionDashboard(ctx context.Context, dashboardID int64) error { - for key, val := range s.provisioned { - for index, dashboard := range val { - if dashboard.DashboardId == dashboardID { - s.provisioned[key] = append(s.provisioned[key][:index], s.provisioned[key][index+1:]...) - } - } - } - return nil -} - -func (s *fakeDashboardProvisioningService) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error { - err := s.UnprovisionDashboard(ctx, dashboardID) - if err != nil { - return err - } - - for index, val := range s.inserted { - if val.Dashboard.Id == dashboardID { - s.inserted = append(s.inserted[:index], s.inserted[util.MinInt(index+1, len(s.inserted)):]...) - } - } - return nil -} - -func (s *fakeDashboardProvisioningService) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { - return nil, nil -} - -func mockGetDashboardQuery(ctx context.Context, cmd *models.GetDashboardQuery) error { - for _, d := range fakeService.getDashboard { - if d.Slug == cmd.Slug { - cmd.Result = d - return nil - } - } - +func mockGetDashboardQuery(_ context.Context, _ *models.GetDashboardQuery) error { return models.ErrDashboardNotFound } diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index e1e588e75f9..040e4699b45 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -5,10 +5,12 @@ import ( "sort" "testing" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) const ( @@ -18,7 +20,8 @@ const ( func TestDuplicatesValidator(t *testing.T) { bus.ClearBusHandlers() - fakeService = mockDashboardProvisioningService() + fakeService := &dashboards.FakeDashboardProvisioning{} + defer fakeService.AssertExpectations(t) bus.AddHandler("test", mockGetDashboardQuery) cfg := &config{ @@ -32,6 +35,11 @@ func TestDuplicatesValidator(t *testing.T) { t.Run("Duplicates validator should collect info about duplicate UIDs and titles within folders", func(t *testing.T) { const folderName = "duplicates-validator-folder" + + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3) + fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(2) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) + folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) require.NoError(t, err) @@ -47,9 +55,11 @@ func TestDuplicatesValidator(t *testing.T) { } reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1.dashboardProvisioningService = fakeService require.NoError(t, err) reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2.dashboardProvisioningService = fakeService require.NoError(t, err) duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2}) @@ -79,6 +89,11 @@ func TestDuplicatesValidator(t *testing.T) { t.Run("Duplicates validator should not collect info about duplicate UIDs and titles within folders for different orgs", func(t *testing.T) { const folderName = "duplicates-validator-folder" + + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3) + fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(2) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2) + folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, folderName) require.NoError(t, err) @@ -94,9 +109,11 @@ func TestDuplicatesValidator(t *testing.T) { } reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1.dashboardProvisioningService = fakeService require.NoError(t, err) reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2.dashboardProvisioningService = fakeService require.NoError(t, err) duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2}) @@ -135,6 +152,10 @@ func TestDuplicatesValidator(t *testing.T) { }) t.Run("Duplicates validator should restrict write access only for readers with duplicates", func(t *testing.T) { + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5) + fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(3) + fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(6) + cfg1 := &config{ Name: "first", Type: "file", OrgID: 1, Folder: "duplicates-validator-folder", Options: map[string]interface{}{"path": twoDashboardsWithUID}, @@ -149,12 +170,15 @@ func TestDuplicatesValidator(t *testing.T) { } reader1, err := NewDashboardFileReader(cfg1, logger, nil) + reader1.dashboardProvisioningService = fakeService require.NoError(t, err) reader2, err := NewDashboardFileReader(cfg2, logger, nil) + reader2.dashboardProvisioningService = fakeService require.NoError(t, err) reader3, err := NewDashboardFileReader(cfg3, logger, nil) + reader3.dashboardProvisioningService = fakeService require.NoError(t, err) duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2, reader3}) diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index c7210b9279a..711b90b7962 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" plugifaces "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" + dashboardservice "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" @@ -20,10 +21,11 @@ import ( ) func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugifaces.Store, - encryptionService encryption.Internal, notificatonService *notifications.NotificationService) (*ProvisioningServiceImpl, error) { + encryptionService encryption.Internal, notificatonService *notifications.NotificationService, + dashboardService dashboardservice.DashboardProvisioningService, +) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, - SQLStore: sqlStore, pluginStore: pluginStore, EncryptionService: encryptionService, NotificationService: notificatonService, @@ -32,6 +34,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p provisionNotifiers: notifiers.Provision, provisionDatasources: datasources.Provision, provisionPlugins: plugins.Provision, + dashboardService: dashboardService, } return s, nil } @@ -88,6 +91,7 @@ type ProvisioningServiceImpl struct { provisionDatasources func(context.Context, string) error provisionPlugins func(context.Context, string, plugifaces.Store) error mutex sync.Mutex + dashboardService dashboardservice.DashboardProvisioningService } func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error { @@ -170,7 +174,7 @@ func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) e func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error { dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards") - dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.SQLStore) + dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService) if err != nil { return errutil.Wrap("Failed to create provisioner", err) } diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index 731b3c0a3ce..8bf248f97fa 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - dboards "github.com/grafana/grafana/pkg/dashboards" + dashboardstore "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" @@ -92,7 +92,7 @@ func setup() *serviceTestStruct { } serviceTest.service = newProvisioningServiceImpl( - func(context.Context, string, dboards.Store) (dashboards.DashboardProvisioner, error) { + func(context.Context, string, dashboardstore.DashboardProvisioningService) (dashboards.DashboardProvisioner, error) { return serviceTest.mock, nil }, nil, diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index 3617b3296bd..e8820b9e1f9 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -29,8 +29,9 @@ var netClient = &http.Client{ } var ( - remoteVersionFetchInterval time.Duration = time.Second * 15 - remoteVersionFetchRetries uint = 4 + remoteVersionFetchInterval time.Duration = time.Second * 15 + remoteVersionFetchRetries uint = 4 + remoteVersionRefreshInterval = time.Minute * 15 ) func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) { @@ -251,3 +252,26 @@ func (rs *RenderingService) getRemotePluginVersion() (string, error) { } return info.Version, nil } + +func (rs *RenderingService) refreshRemotePluginVersion() { + newVersion, err := rs.getRemotePluginVersion() + if err != nil { + rs.log.Info("Failed to refresh remote plugin version", "err", err) + return + } + + if newVersion == "" { + // the image-renderer could have been temporary unavailable - skip updating the version + rs.log.Debug("Received empty version when trying to refresh remote plugin version") + return + } + + currentVersion := rs.Version() + if currentVersion != newVersion { + rs.versionMutex.Lock() + defer rs.versionMutex.Unlock() + + rs.log.Info("Updating remote plugin version", "currentVersion", currentVersion, "newVersion", newVersion) + rs.version = newVersion + } +} diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index d62a4447ed1..2de807cb5a4 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -120,8 +120,19 @@ func (rs *RenderingService) Run(ctx context.Context) error { }) rs.renderAction = rs.renderViaHTTP rs.renderCSVAction = rs.renderCSVViaHTTP - <-ctx.Done() - return nil + + refreshTicker := time.NewTicker(remoteVersionRefreshInterval) + + for { + select { + case <-refreshTicker.C: + go rs.refreshRemotePluginVersion() + case <-ctx.Done(): + rs.log.Debug("Grafana is shutting down - stopping image-renderer version refresh") + refreshTicker.Stop() + return nil + } + } } if rs.pluginAvailable() { diff --git a/pkg/services/searchV2/auth.go b/pkg/services/searchV2/auth.go new file mode 100644 index 00000000000..4f2d8cb6706 --- /dev/null +++ b/pkg/services/searchV2/auth.go @@ -0,0 +1,67 @@ +package searchV2 + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/permissions" +) + +// ResourceFilter checks if a given a uid (resource identifier) check if we have the requested permission +type ResourceFilter func(uid string) bool + +// FutureAuthService eventually implemented by the security service +type FutureAuthService interface { + GetDashboardReadFilter(user *models.SignedInUser) (ResourceFilter, error) +} + +type simpleSQLAuthService struct { + sql *sqlstore.SQLStore +} + +type dashIdQueryResult struct { + UID string `xorm:"uid"` +} + +func (a *simpleSQLAuthService) GetDashboardReadFilter(user *models.SignedInUser) (ResourceFilter, error) { + // this filter works on the legacy `dashboard_acl` table + // we will also need to use `accesscontrol` after https://github.com/grafana/grafana/pull/44702/files is merged + // see https://github.com/grafana/grafana/blob/e355bd6d3a04111b8c9959f85e81beabbeb746bf/pkg/services/sqlstore/permissions/dashboard.go#L84 + filter := permissions.DashboardPermissionFilter{ + OrgRole: user.OrgRole, + OrgId: user.OrgId, + Dialect: a.sql.Dialect, + UserId: user.UserId, + PermissionLevel: models.PERMISSION_VIEW, + } + + rows := make([]*dashIdQueryResult, 0) + + err := a.sql.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + sql, params := filter.Where() + sess.Table("dashboard"). + Where(sql, params...). + Cols("uid") + + err := sess.Find(&rows) + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return nil, err + } + + uids := make(map[string]bool, len(rows)+1) + for i := 0; i < len(rows); i++ { + uids[rows[i].UID] = true + } + + return func(uid string) bool { + return uids[uid] + }, err +} diff --git a/pkg/services/searchV2/extract/dashboard.go b/pkg/services/searchV2/extract/dashboard.go index dac4a12305a..38e174e5b47 100644 --- a/pkg/services/searchV2/extract/dashboard.go +++ b/pkg/services/searchV2/extract/dashboard.go @@ -1,14 +1,13 @@ package extract import ( - "fmt" "io" jsoniter "github.com/json-iterator/go" ) func logf(format string, a ...interface{}) { - fmt.Printf(format, a...) + //fmt.Printf(format, a...) } // nolint:gocyclo diff --git a/pkg/services/searchV2/service.go b/pkg/services/searchV2/service.go index d93d31dc560..c2ea11f6923 100644 --- a/pkg/services/searchV2/service.go +++ b/pkg/services/searchV2/service.go @@ -16,12 +16,16 @@ import ( ) type StandardSearchService struct { - sql *sqlstore.SQLStore + sql *sqlstore.SQLStore + auth FutureAuthService // eventually injected from elsewhere } func ProvideService(sql *sqlstore.SQLStore) SearchService { return &StandardSearchService{ sql: sql, + auth: &simpleSQLAuthService{ + sql: sql, + }, } } @@ -29,6 +33,7 @@ type dashMeta struct { id int64 is_folder bool folder_id int64 + slug string created time.Time updated time.Time dash *extract.DashboardInfo @@ -37,13 +42,34 @@ type dashMeta struct { func (s *StandardSearchService) DoDashboardQuery(ctx context.Context, user *backend.User, orgId int64, query DashboardQuery) *backend.DataResponse { rsp := &backend.DataResponse{} - if user == nil || user.Role != string(models.ROLE_ADMIN) { - rsp.Error = fmt.Errorf("search is only supported for admin users while in early development") + // Load and parse all dashboards for given orgId + dash, err := loadDashboards(ctx, orgId, s.sql) + if err != nil { + rsp.Error = err return rsp } - // Load and parse all dashboards for given orgId - dash, err := loadDashboards(ctx, orgId, s.sql) + // TODO - get user from context? + getSignedInUserQuery := &models.GetSignedInUserQuery{ + Login: user.Login, + Email: user.Email, + OrgId: orgId, + } + + err = s.sql.GetSignedInUser(ctx, getSignedInUserQuery) + if err != nil { + fmt.Printf("error while retrieving user %s\n", err) + rsp.Error = fmt.Errorf("auth error") + return rsp + } + + if getSignedInUserQuery.Result == nil { + fmt.Printf("no user %s", user.Email) + rsp.Error = fmt.Errorf("auth error") + return rsp + } + + dash, err = s.applyAuthFilter(getSignedInUserQuery.Result, dash) if err != nil { rsp.Error = err return rsp @@ -54,10 +80,27 @@ func (s *StandardSearchService) DoDashboardQuery(ctx context.Context, user *back return rsp } +func (s *StandardSearchService) applyAuthFilter(user *models.SignedInUser, dash []dashMeta) ([]dashMeta, error) { + filter, err := s.auth.GetDashboardReadFilter(user) + if err != nil { + return nil, err + } + + // create a list of all viewable dashboards for this user + res := make([]dashMeta, 0, len(dash)) + for _, dash := range dash { + if filter(dash.dash.UID) { + res = append(res, dash) + } + } + return res, nil +} + type dashDataQueryResult struct { Id int64 - IsFolder bool `xorm:"is_folder"` - FolderID int64 `xorm:"folder_id"` + IsFolder bool `xorm:"is_folder"` + FolderID int64 `xorm:"folder_id"` + Slug string `xorm:"slug"` Data []byte Created time.Time Updated time.Time @@ -76,7 +119,7 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([ sess.Table("dashboard"). Where("org_id = ?", orgID). - Cols("id", "is_folder", "folder_id", "data", "created", "updated") + Cols("id", "is_folder", "folder_id", "data", "slug", "created", "updated") err := sess.Find(&rows) if err != nil { @@ -90,6 +133,7 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([ id: row.Id, is_folder: row.IsFolder, folder_id: row.FolderID, + slug: row.Slug, created: row.Created, updated: row.Updated, dash: dash, @@ -136,6 +180,7 @@ func metaToFrame(meta []dashMeta) data.Frames { dashID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) dashUID := data.NewFieldFromFieldType(data.FieldTypeString, 0) + dashURL := data.NewFieldFromFieldType(data.FieldTypeString, 0) dashFolderID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) dashName := data.NewFieldFromFieldType(data.FieldTypeString, 0) dashDescr := data.NewFieldFromFieldType(data.FieldTypeString, 0) @@ -153,6 +198,12 @@ func metaToFrame(meta []dashMeta) data.Frames { dashSchemaVersion.Name = "SchemaVersion" dashCreated.Name = "Created" dashUpdated.Name = "Updated" + dashURL.Name = "URL" + dashURL.Config = &data.FieldConfig{ + Links: []data.DataLink{ + {Title: "link", URL: "${__value.text}"}, + }, + } dashTags.Config = &data.FieldConfig{ Custom: map[string]interface{}{ @@ -199,6 +250,9 @@ func metaToFrame(meta []dashMeta) data.Frames { dashCreated.Append(row.created) dashUpdated.Append(row.updated) + url := fmt.Sprintf("/d/%s/%s", row.dash.UID, row.slug) + dashURL.Append(url) + // stats schemaVersionCounter.add(strconv.FormatInt(row.dash.SchemaVersion, 10)) @@ -226,7 +280,7 @@ func metaToFrame(meta []dashMeta) data.Frames { return data.Frames{ data.NewFrame("folders", folderID, folderUID, folderName), - data.NewFrame("dashboards", dashID, dashUID, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated), + data.NewFrame("dashboards", dashID, dashUID, dashURL, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated), data.NewFrame("panels", panelDashID, panelID, panelName, panelDescr, panelType), panelTypeCounter.toFrame("panel-type-counts"), schemaVersionCounter.toFrame("schema-version-counts"), diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 3c5ade4d439..1d26c54ce01 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -71,6 +71,8 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount)) + serviceAccountsRoute.Patch("/:serviceAccountId", auth(middleware.ReqOrgAdmin, + accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.updateServiceAccount)) serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount)) serviceAccountsRoute.Post("/upgradeall", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.UpgradeServiceAccounts)) serviceAccountsRoute.Post("/convert/:keyId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.ConvertToServiceAccount)) @@ -86,7 +88,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( // POST /api/serviceaccounts func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) response.Response { - cmd := serviceaccounts.CreateServiceaccountForm{} + cmd := serviceaccounts.CreateServiceAccountForm{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "Bad request data", err) } @@ -120,7 +122,7 @@ func (api *ServiceAccountsAPI) UpgradeServiceAccounts(ctx *models.ReqContext) re if err := api.store.UpgradeServiceAccounts(ctx.Req.Context()); err == nil { return response.Success("service accounts upgraded") } else { - return response.Error(500, "Internal server error", err) + return response.Error(http.StatusInternalServerError, "Internal server error", err) } } @@ -148,8 +150,8 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons saIDs[strconv.FormatInt(serviceAccounts[i].Id, 10)] = true } - metadata, err := api.getAccessControlMetadata(c, saIDs) - if err == nil && len(metadata) != 0 { + metadata := api.getAccessControlMetadata(c, saIDs) + if len(metadata) > 0 { for i := range serviceAccounts { serviceAccounts[i].AccessControl = metadata[strconv.FormatInt(serviceAccounts[i].Id, 10)] } @@ -158,18 +160,21 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons return response.JSON(http.StatusOK, serviceAccounts) } -func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) (map[string]accesscontrol.Metadata, error) { +func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata { if api.accesscontrol.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil + return map[string]accesscontrol.Metadata{} } - userPermissions, err := api.accesscontrol.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - api.log.Warn("could not fetch accesscontrol metadata for teams", "error", err) - return nil, err + if c.SignedInUser.Permissions == nil { + return map[string]accesscontrol.Metadata{} } - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "serviceaccounts", saIDs), nil + permissions, ok := c.SignedInUser.Permissions[c.OrgId] + if !ok { + return map[string]accesscontrol.Metadata{} + } + + return accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "serviceaccounts", saIDs) } func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response { @@ -189,3 +194,31 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re } return response.JSON(http.StatusOK, serviceAccount) } + +func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) response.Response { + scopeID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) + if err != nil { + return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + } + + cmd := &serviceaccounts.UpdateServiceAccountForm{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + if cmd.Role != nil && !cmd.Role.IsValid() { + return response.Error(http.StatusBadRequest, "Invalid role specified", nil) + } + + resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, cmd) + if err != nil { + switch { + case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound): + return response.Error(http.StatusNotFound, "Failed to retrieve service account", err) + default: + return response.Error(http.StatusInternalServerError, "Failed update service account", err) + } + } + + return response.JSON(http.StatusOK, resp) +} diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 84172674f3e..bff5d52cb61 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -1,9 +1,11 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -15,13 +17,13 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/services/serviceaccounts/database" ) var ( @@ -60,7 +62,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { } serviceAccountRequestScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -84,7 +86,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { } serviceAccountRequestScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, createduser.Id)).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -96,8 +98,11 @@ func serviceAccountRequestScenario(t *testing.T, httpMethod string, endpoint str fn(httpMethod, endpoint, user) } -func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock, sqlStore *sqlstore.SQLStore) *web.Mux { - a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, database.NewServiceAccountsStore(sqlStore), sqlStore) +func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, + routerRegister routing.RouteRegister, + acmock *accesscontrolmock.Mock, + sqlStore *sqlstore.SQLStore, saStore serviceaccounts.Store) *web.Mux { + a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore, sqlStore) a.RegisterAPIEndpoints(featuremgmt.WithFeatures(featuremgmt.FlagServiceAccounts)) a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration @@ -187,7 +192,7 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) scopeID = int(createdUser.Id) } - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID)) @@ -205,3 +210,129 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { }) } } + +func newString(s string) *string { + return &s +} + +func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { + store := sqlstore.InitTestDB(t) + svcmock := tests.ServiceAccountMock{} + type testUpdateSATestCase struct { + desc string + user *tests.TestUser + expectedCode int + acmock *accesscontrolmock.Mock + body *serviceaccounts.UpdateServiceAccountForm + Id int + } + + role := models.ROLE_ADMIN + var invalidRole models.RoleType = "InvalidRole" + testCases := []testUpdateSATestCase{ + { + desc: "should be ok to update serviceaccount with permissions", + user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Editor", Name: "Unaltered"}, + body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &role}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusOK, + }, + { + desc: "bad request when invalid role", + user: &tests.TestUser{Login: "servicetest3@admin", IsServiceAccount: true, Role: "Invalid", Name: "Unaltered"}, + body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("NameB"), Role: &invalidRole}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusBadRequest, + }, + { + desc: "should be forbidden to update serviceaccount if no permissions", + user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true}, + body: nil, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{}, nil + }, + false, + ), + expectedCode: http.StatusForbidden, + }, + { + desc: "should be not found when the user doesnt exist", + user: nil, + body: nil, + Id: 12, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusNotFound, + }, + } + + var requestResponse = func(server *web.Mux, httpMethod, requestpath string, body io.Reader) *httptest.ResponseRecorder { + req, err := http.NewRequest(httpMethod, requestpath, body) + req.Header.Add("Content-Type", "application/json") + require.NoError(t, err) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + return recorder + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + serviceAccountRequestScenario(t, http.MethodPatch, serviceaccountIDPath, tc.user, func(httpmethod string, endpoint string, user *tests.TestUser) { + scopeID := tc.Id + if tc.user != nil { + createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) + scopeID = int(createdUser.Id) + } + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) + + var rawBody io.Reader = http.NoBody + if tc.body != nil { + body, err := json.Marshal(tc.body) + require.NoError(t, err) + rawBody = bytes.NewReader(body) + } + + actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID), rawBody) + + actualCode := actual.Code + require.Equal(t, tc.expectedCode, actualCode) + + if actualCode == http.StatusOK { + actualBody := map[string]interface{}{} + err := json.Unmarshal(actual.Body.Bytes(), &actualBody) + require.NoError(t, err) + assert.Equal(t, scopeID, int(actualBody["id"].(float64))) + assert.Equal(t, string(*tc.body.Role), actualBody["role"].(string)) + assert.Equal(t, *tc.body.Name, actualBody["name"].(string)) + assert.Equal(t, tc.user.Login, actualBody["login"].(string)) + + // Ensure the user was updated in DB + query := models.GetOrgUsersQuery{UserID: int64(scopeID), OrgId: 1, IsServiceAccount: true} + err = store.GetOrgUsers(context.Background(), &query) + require.NoError(t, err) + require.Equal(t, *tc.body.Name, query.Result[0].Name) + require.Equal(t, string(*tc.body.Role), query.Result[0].Role) + } + }) + }) + } +} diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index bfeef7b4185..a66bcc226ff 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -16,6 +16,26 @@ import ( const failedToDeleteMsg = "Failed to delete API key" +type TokenDTO struct { + Id int64 `json:"id"` + Name string `json:"name"` + Role models.RoleType `json:"role"` + Created *time.Time `json:"created"` + Expiration *time.Time `json:"expiration"` + SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"` + HasExpired bool `json:"hasExpired"` +} + +func hasExpired(expiration *int64) bool { + if expiration == nil { + return false + } + v := time.Unix(*expiration, 0) + return (v).Before(time.Now()) +} + +const sevenDaysAhead = 7 * 24 * time.Hour + func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { @@ -23,24 +43,34 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo } if saTokens, err := api.store.ListTokens(ctx.Req.Context(), ctx.OrgId, saID); err == nil { - result := make([]*models.ApiKeyDTO, len(saTokens)) + result := make([]*TokenDTO, len(saTokens)) for i, t := range saTokens { var expiration *time.Time = nil + var secondsUntilExpiration float64 = 0 + + isExpired := hasExpired(t.Expires) if t.Expires != nil { v := time.Unix(*t.Expires, 0) expiration = &v + if !isExpired && (*expiration).Before(time.Now().Add(sevenDaysAhead)) { + secondsUntilExpiration = time.Until(*expiration).Seconds() + } } - result[i] = &models.ApiKeyDTO{ - Id: t.Id, - Name: t.Name, - Role: t.Role, - Expiration: expiration, + + result[i] = &TokenDTO{ + Id: t.Id, + Name: t.Name, + Role: t.Role, + Created: &t.Created, + Expiration: expiration, + SecondsUntilExpiration: &secondsUntilExpiration, + HasExpired: isExpired, } } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } else { - return response.Error(500, "Internal server error", err) + return response.Error(http.StatusInternalServerError, "Internal server error", err) } } @@ -71,33 +101,33 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon cmd.OrgId = c.OrgId if !cmd.Role.IsValid() { - return response.Error(400, "Invalid role specified", nil) + return response.Error(http.StatusBadRequest, "Invalid role specified", nil) } if api.cfg.ApiKeyMaxSecondsToLive != -1 { if cmd.SecondsToLive == 0 { - return response.Error(400, "Number of seconds before expiration should be set", nil) + return response.Error(http.StatusBadRequest, "Number of seconds before expiration should be set", nil) } if cmd.SecondsToLive > api.cfg.ApiKeyMaxSecondsToLive { - return response.Error(400, "Number of seconds before expiration is greater than the global limit", nil) + return response.Error(http.StatusBadRequest, "Number of seconds before expiration is greater than the global limit", nil) } } newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name) if err != nil { - return response.Error(500, "Generating API key failed", err) + return response.Error(http.StatusInternalServerError, "Generating API key failed", err) } cmd.Key = newKeyInfo.HashedKey if err := api.apiKeyStore.AddAPIKey(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrInvalidApiKeyExpiration) { - return response.Error(400, err.Error(), nil) + return response.Error(http.StatusBadRequest, err.Error(), nil) } if errors.Is(err, models.ErrDuplicateApiKey) { - return response.Error(409, err.Error(), nil) + return response.Error(http.StatusConflict, err.Error(), nil) } - return response.Error(500, "Failed to add API Key", err) + return response.Error(http.StatusInternalServerError, "Failed to add API Key", err) } result := &dtos.NewApiKeyResult{ @@ -106,7 +136,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon Key: newKeyInfo.ClientSecret, } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } // DeleteToken deletes service account tokens @@ -134,9 +164,9 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon // confirm API key belongs to service account. TODO: refactor get & delete to single call cmdGet := &models.GetApiKeyByIdQuery{ApiKeyId: tokenID} if err = api.apiKeyStore.GetApiKeyById(c.Req.Context(), cmdGet); err != nil { - status := 404 + status := http.StatusNotFound if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) { - status = 500 + status = http.StatusInternalServerError } else { err = models.ErrApiKeyNotFound } @@ -146,14 +176,14 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon // verify service account ID matches the URL if *cmdGet.Result.ServiceAccountId != saID { - return response.Error(404, failedToDeleteMsg, err) + return response.Error(http.StatusNotFound, failedToDeleteMsg, err) } cmdDel := &models.DeleteApiKeyCommand{Id: tokenID, OrgId: c.OrgId} if err = api.apiKeyStore.DeleteApiKey(c.Req.Context(), cmdDel); err != nil { - status := 404 + status := http.StatusNotFound if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) { - status = 500 + status = http.StatusInternalServerError } else { err = models.ErrApiKeyNotFound } diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index b404b08549f..a90af8ec96f 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" @@ -17,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/web" @@ -29,7 +31,7 @@ const ( serviceaccountIDTokensDetailPath = "/api/serviceaccounts/%v/tokens/%v" // #nosec G101 ) -func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64) *models.ApiKey { +func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey { key, err := apikeygen.New(orgID, keyName) require.NoError(t, err) cmd := models.AddApiKeyCommand{ @@ -37,13 +39,12 @@ func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64) *mo Role: "Viewer", OrgId: orgID, Key: key.HashedKey, - SecondsToLive: 0, + SecondsToLive: secondsToLive, ServiceAccountId: &saID, Result: &models.ApiKey{}, } err = bus.Dispatch(context.Background(), &cmd) require.NoError(t, err) - return cmd.Result } @@ -129,7 +130,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { bodyString = string(b) } - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, http.MethodPost, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code @@ -215,11 +216,11 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id) + token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id, 1) endpoint := fmt.Sprintf(serviceaccountIDTokensDetailPath, sa.Id, token.Id) bodyString := "" - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, http.MethodDelete, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code @@ -238,3 +239,122 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { }) } } + +type saStoreMockTokens struct { + serviceaccounts.Store + saAPIKeys []*models.ApiKey +} + +func (s *saStoreMockTokens) ListTokens(ctx context.Context, orgID, saID int64) ([]*models.ApiKey, error) { + return s.saAPIKeys, nil +} + +func TestServiceAccountsAPI_ListTokens(t *testing.T) { + store := sqlstore.InitTestDB(t) + svcmock := tests.ServiceAccountMock{} + sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true}) + + type testCreateSAToken struct { + desc string + tokens []*models.ApiKey + expectedHasExpired bool + expectedResponseBodyField string + expectedCode int + acmock *accesscontrolmock.Mock + } + + var saId int64 = 1 + var timeInFuture = time.Now().Add(time.Second * 100).Unix() + var timeInPast = time.Now().Add(-time.Second * 100).Unix() + + testCases := []testCreateSAToken{ + { + desc: "should be able to list serviceaccount with no expiration date", + tokens: []*models.ApiKey{{ + Id: 1, + OrgId: 1, + ServiceAccountId: &saId, + Expires: nil, + Name: "Test1", + }}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil + }, + false, + ), + expectedHasExpired: false, + expectedResponseBodyField: "hasExpired", + expectedCode: http.StatusOK, + }, + { + desc: "should be able to list serviceaccount with secondsUntilExpiration", + tokens: []*models.ApiKey{{ + Id: 1, + OrgId: 1, + ServiceAccountId: &saId, + Expires: &timeInFuture, + Name: "Test2", + }}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil + }, + false, + ), + expectedHasExpired: false, + expectedResponseBodyField: "secondsUntilExpiration", + expectedCode: http.StatusOK, + }, + { + desc: "should be able to list serviceaccount with expired token", + tokens: []*models.ApiKey{{ + Id: 1, + OrgId: 1, + ServiceAccountId: &saId, + Expires: &timeInPast, + Name: "Test3", + }}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil + }, + false, + ), + expectedHasExpired: true, + expectedResponseBodyField: "secondsUntilExpiration", + expectedCode: http.StatusOK, + }, + } + + var requestResponse = func(server *web.Mux, httpMethod, requestpath string, requestBody io.Reader) *httptest.ResponseRecorder { + req, err := http.NewRequest(httpMethod, requestpath, requestBody) + require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + return recorder + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + endpoint := fmt.Sprintf(serviceaccountIDPath+"/tokens", sa.Id) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, &saStoreMockTokens{saAPIKeys: tc.tokens}) + actual := requestResponse(server, http.MethodGet, endpoint, http.NoBody) + + actualCode := actual.Code + actualBody := []map[string]interface{}{} + + _ = json.Unmarshal(actual.Body.Bytes(), &actualBody) + require.Equal(t, tc.expectedCode, actualCode, endpoint, actualBody) + + require.Equal(t, tc.expectedCode, actualCode) + require.Equal(t, tc.expectedHasExpired, actualBody[0]["hasExpired"]) + _, exists := actualBody[0][tc.expectedResponseBodyField] + require.Equal(t, exists, true) + }) + } +} diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index e6f1191a584..593a23b03c3 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -25,7 +25,7 @@ func NewServiceAccountsStore(store *sqlstore.SQLStore) *ServiceAccountsStoreImpl } } -func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, sa *serviceaccounts.CreateServiceaccountForm) (saDTO *serviceaccounts.ServiceAccountDTO, err error) { +func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, sa *serviceaccounts.CreateServiceAccountForm) (saDTO *serviceaccounts.ServiceAccountDTO, err error) { // create a new service account - "user" with empty permissions generatedLogin := "Service-Account-" + uuid.New().String() cmd := models.CreateUserCommand{ @@ -128,9 +128,9 @@ func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgID int64, err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { var sess *xorm.Session - sess = dbSession.Limit(100, 0). + sess = dbSession. Join("inner", "user", "user.id = api_key.service_account_id"). - Where("user.org_id=? AND user.id=? AND ( expires IS NULL or expires >= ?)", orgID, serviceAccountID, time.Now().Unix()). + Where("user.org_id=? AND user.id=?", orgID, serviceAccountID). Asc("name") return sess.Find(&result) @@ -185,6 +185,64 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o return saProfile, err } +func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, + orgID, serviceAccountID int64, + saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { + updatedUser := &models.OrgUserDTO{} + + err := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + query := models.GetOrgUsersQuery{UserID: serviceAccountID, OrgId: orgID, IsServiceAccount: true} + if err := s.sqlStore.GetOrgUsers(ctx, &query); err != nil { + return err + } + if len(query.Result) != 1 { + return serviceaccounts.ErrServiceAccountNotFound + } + + updatedUser = query.Result[0] + + if saForm.Name == nil && saForm.Role == nil { + return nil + } + + updateTime := time.Now() + if saForm.Role != nil { + var orgUser models.OrgUser + orgUser.Role = *saForm.Role + orgUser.Updated = updateTime + + if _, err := sess.ID(orgUser.Id).Update(&orgUser); err != nil { + return err + } + + updatedUser.Role = string(*saForm.Role) + } + + if saForm.Name != nil { + user := models.User{ + Name: *saForm.Name, + Updated: updateTime, + } + + if _, err := sess.ID(serviceAccountID).Update(&user); err != nil { + return err + } + + updatedUser.Name = *saForm.Name + } + + return nil + }) + + return &serviceaccounts.ServiceAccountDTO{ + Id: updatedUser.UserId, + Name: updatedUser.Name, + Login: updatedUser.Login, + Role: updatedUser.Role, + OrgId: updatedUser.OrgId, + }, err +} + func contains(s []int64, e int64) bool { for _, a := range s { if a == e { diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 02a8b8f0154..e74aaa5a02c 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -49,7 +49,7 @@ func ProvideServiceAccountsService( return s, nil } -func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) { +func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { if !sa.features.IsEnabled(featuremgmt.FlagServiceAccounts) { sa.log.Debug(ServiceAccountFeatureToggleNotFound) return nil, nil diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index d5577e2ecc7..81ad0337b4d 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -3,6 +3,7 @@ package serviceaccounts import ( "time" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" ) @@ -22,7 +23,12 @@ type ServiceAccount struct { Id int64 } -type CreateServiceaccountForm struct { +type UpdateServiceAccountForm struct { + Name *string `json:"name"` + Role *models.RoleType `json:"role"` +} + +type CreateServiceAccountForm struct { OrgID int64 `json:"-"` Name string `json:"name" binding:"Required"` } diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 80b2d258e1f..3aa6d895169 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -8,13 +8,14 @@ import ( // this should reflect the api type Service interface { - CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*ServiceAccountDTO, error) + CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error } type Store interface { - CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*ServiceAccountDTO, error) + CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) ListServiceAccounts(ctx context.Context, orgID, serviceAccountID int64) ([]*ServiceAccountDTO, error) + UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountDTO, error) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error UpgradeServiceAccounts(ctx context.Context) error diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 2262438e022..17e9f16d9b2 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -13,14 +13,23 @@ import ( ) type TestUser struct { + Name string + Role string Login string IsServiceAccount bool } func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser TestUser) *models.User { + role := string(models.ROLE_VIEWER) + if testUser.Role != "" { + role = testUser.Role + } + u1, err := sqlStore.CreateUser(context.Background(), models.CreateUserCommand{ Login: testUser.Login, IsServiceAccount: testUser.IsServiceAccount, + DefaultOrgRole: role, + Name: testUser.Name, }) require.NoError(t, err) return u1 @@ -29,7 +38,7 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser // create mock for serviceaccountservice type ServiceAccountMock struct{} -func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) { +func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { return nil, nil } @@ -65,13 +74,14 @@ type Calls struct { UpgradeServiceAccounts []interface{} ConvertServiceAccounts []interface{} ListTokens []interface{} + UpdateServiceAccount []interface{} } type ServiceAccountsStoreMock struct { Calls Calls } -func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, cmd *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) { +func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, cmd *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { // now we can test that the mock has these calls when we call the function s.Calls.CreateServiceAccount = append(s.Calls.CreateServiceAccount, []interface{}{ctx, cmd}) return nil, nil @@ -106,3 +116,11 @@ func (s *ServiceAccountsStoreMock) RetrieveServiceAccount(ctx context.Context, o s.Calls.RetrieveServiceAccount = append(s.Calls.RetrieveServiceAccount, []interface{}{ctx, orgID, serviceAccountID}) return nil, nil } + +func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context, + orgID, serviceAccountID int64, + saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { + s.Calls.UpdateServiceAccount = append(s.Calls.UpdateServiceAccount, []interface{}{ctx, orgID, serviceAccountID, saForm}) + + return nil, nil +} diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index b7d536d765e..0d6bd261415 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -513,7 +513,7 @@ func (ss *SQLStore) SetAlertNotificationStateToCompleteCommand(ctx context.Conte } func (ss *SQLStore) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { - return withDbSession(ctx, x, func(sess *DBSession) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { newVersion := cmd.Version + 1 sql := `UPDATE alert_notification_state SET state = ?, diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index d7a07a72eec..6241d347274 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -4,18 +4,14 @@ import ( "context" "fmt" "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/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/services/sqlstore/permissions" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/util" + "github.com/prometheus/client_golang/prometheus" ) var shadowSearchCounter = prometheus.NewCounterVec( @@ -27,11 +23,6 @@ var shadowSearchCounter = prometheus.NewCounterVec( ) func init() { - bus.AddHandler("sql", GetDashboardSlugById) - bus.AddHandler("sql", GetDashboardsByPluginId) - bus.AddHandler("sql", GetDashboardPermissionsForUser) - bus.AddHandler("sql", HasAdminPermissionInFolders) - prometheus.MustRegister(shadowSearchCounter) } @@ -43,172 +34,16 @@ func (ss *SQLStore) addDashboardQueryAndCommandHandlers() { bus.AddHandler("sql", ss.DeleteDashboard) bus.AddHandler("sql", ss.GetDashboards) bus.AddHandler("sql", ss.HasEditPermissionInFolders) + bus.AddHandler("sql", ss.GetDashboardPermissionsForUser) + bus.AddHandler("sql", ss.GetDashboardsByPluginId) + bus.AddHandler("sql", ss.GetDashboardSlugById) + bus.AddHandler("sql", ss.HasAdminPermissionInFolders) } var generateNewUid func() string = util.GenerateShortUID -func (ss *SQLStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) { - err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error { - return saveDashboard(sess, &cmd) - }) - return cmd.Result, err -} - -func saveDashboard(sess *DBSession, cmd *models.SaveDashboardCommand) error { - dash := cmd.GetDashboardModel() - - userId := cmd.UserId - - if userId == 0 { - userId = -1 - } - - if dash.Id > 0 { - var existing models.Dashboard - dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) - if err != nil { - return err - } - if !dashWithIdExists { - return models.ErrDashboardNotFound - } - - // check for is someone else has written in between - if dash.Version != existing.Version { - if cmd.Overwrite { - dash.SetVersion(existing.Version) - } else { - return models.ErrDashboardVersionMismatch - } - } - - // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && !cmd.Overwrite { - return models.UpdatePluginDashboardError{PluginId: existing.PluginId} - } - } - - if dash.Uid == "" { - uid, err := generateNewDashboardUid(sess, dash.OrgId) - if err != nil { - return err - } - dash.SetUid(uid) - } - - parentVersion := dash.Version - var affectedRows int64 - var err error - - if dash.Id == 0 { - dash.SetVersion(1) - dash.Created = time.Now() - dash.CreatedBy = userId - dash.Updated = time.Now() - dash.UpdatedBy = userId - metrics.MApiDashboardInsert.Inc() - affectedRows, err = sess.Insert(dash) - } else { - dash.SetVersion(dash.Version + 1) - - if !cmd.UpdatedAt.IsZero() { - dash.Updated = cmd.UpdatedAt - } else { - dash.Updated = time.Now() - } - - dash.UpdatedBy = userId - - affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) - } - - if err != nil { - return err - } - - if affectedRows == 0 { - return models.ErrDashboardNotFound - } - - dashVersion := &models.DashboardVersion{ - DashboardId: dash.Id, - ParentVersion: parentVersion, - RestoredFrom: cmd.RestoredFrom, - Version: dash.Version, - Created: time.Now(), - CreatedBy: dash.UpdatedBy, - Message: cmd.Message, - Data: dash.Data, - } - - // insert version entry - if affectedRows, err = sess.Insert(dashVersion); err != nil { - return err - } else if affectedRows == 0 { - return models.ErrDashboardNotFound - } - - // delete existing tags - _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id) - if err != nil { - return err - } - - // insert new tags - tags := dash.GetTags() - if len(tags) > 0 { - for _, tag := range tags { - if _, err := sess.Insert(&DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { - return err - } - } - } - - cmd.Result = dash - - return nil -} - -func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) { - for i := 0; i < 3; i++ { - uid := generateNewUid() - - exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&models.Dashboard{}) - if err != nil { - return "", err - } - - if !exists { - return uid, nil - } - } - - return "", models.ErrDashboardFailedGenerateUniqueUid -} - -// GetDashboardByTitle gets a dashboard by its title. -func (ss *SQLStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { - if title == "" { - return nil, models.ErrDashboardIdentifierNotSet - } - - // 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.Table(&models.Dashboard{}).Where("is_folder = " + dialect.BooleanStr(true)).Where("folder_id=0").Get(&dashboard) - if err != nil { - return nil, err - } - if !has { - return nil, models.ErrDashboardNotFound - } - dashboard.SetId(dashboard.Id) - dashboard.SetUid(dashboard.Uid) - return &dashboard, nil -} - func (ss *SQLStore) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error { - return withDbSession(ctx, x, func(dbSession *DBSession) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { if query.Id == 0 && len(query.Slug) == 0 && len(query.Uid) == 0 { return models.ErrDashboardIdentifierNotSet } @@ -243,7 +78,7 @@ type DashboardSearchProjection struct { SortMeta int64 } -func (ss *SQLStore) findDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { +func (ss *SQLStore) FindDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { filters := []interface{}{ permissions.DashboardPermissionFilter{ OrgRole: query.SignedInUser.OrgRole, @@ -315,7 +150,7 @@ func (ss *SQLStore) findDashboards(ctx context.Context, query *search.FindPersis } func (ss *SQLStore) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error { - res, err := ss.findDashboards(ctx, query) + res, err := ss.FindDashboards(ctx, query) if err != nil { return err } @@ -375,7 +210,8 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard } func (ss *SQLStore) GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error { - sql := `SELECT + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + sql := `SELECT COUNT(*) as count, term FROM dashboard @@ -384,10 +220,11 @@ func (ss *SQLStore) GetDashboardTags(ctx context.Context, query *models.GetDashb GROUP BY term ORDER BY term` - query.Result = make([]*models.DashboardTagCloudItem, 0) - sess := x.SQL(sql, query.OrgId) - err := sess.Find(&query.Result) - return err + query.Result = make([]*models.DashboardTagCloudItem, 0) + sess := dbSession.SQL(sql, query.OrgId) + err := sess.Find(&query.Result) + return err + }) } func (ss *SQLStore) DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error { @@ -490,65 +327,68 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error } func (ss *SQLStore) GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error { - if len(query.DashboardIds) == 0 { - return models.ErrCommandValidationFailed - } + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + if len(query.DashboardIds) == 0 { + return models.ErrCommandValidationFailed + } - var dashboards = make([]*models.Dashboard, 0) + var dashboards = make([]*models.Dashboard, 0) - err := x.In("id", query.DashboardIds).Find(&dashboards) - query.Result = dashboards - return err + err := dbSession.In("id", query.DashboardIds).Find(&dashboards) + query.Result = dashboards + return err + }) } // GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s) // The function takes in a list of dashboard ids and the user id and role -func GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error { - if len(query.DashboardIds) == 0 { - return models.ErrCommandValidationFailed - } - - if query.OrgRole == models.ROLE_ADMIN { - var permissions = make([]*models.DashboardPermissionForUser, 0) - for _, d := range query.DashboardIds { - permissions = append(permissions, &models.DashboardPermissionForUser{ - DashboardId: d, - Permission: models.PERMISSION_ADMIN, - PermissionName: models.PERMISSION_ADMIN.String(), - }) +func (ss *SQLStore) GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + if len(query.DashboardIds) == 0 { + return models.ErrCommandValidationFailed } - query.Result = permissions - return nil - } + if query.OrgRole == models.ROLE_ADMIN { + var permissions = make([]*models.DashboardPermissionForUser, 0) + for _, d := range query.DashboardIds { + permissions = append(permissions, &models.DashboardPermissionForUser{ + DashboardId: d, + Permission: models.PERMISSION_ADMIN, + PermissionName: models.PERMISSION_ADMIN.String(), + }) + } + query.Result = permissions - params := make([]interface{}, 0) + return nil + } - // check dashboards that have ACLs via user id, team id or role - sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission + params := make([]interface{}, 0) + + // check dashboards that have ACLs via user id, team id or role + sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission FROM dashboard AS d LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id LEFT JOIN team_member as ugm on ugm.team_id = da.team_id LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? ` - params = append(params, query.UserId) + params = append(params, query.UserId) - // check the user's role for dashboards that do not have hasAcl set - sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?` - params = append(params, query.UserId) - params = append(params, query.OrgId) + // check the user's role for dashboards that do not have hasAcl set + sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?` + params = append(params, query.UserId) + params = append(params, query.OrgId) - sql += ` + sql += ` LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role UNION SELECT 2 AS permission, 'Editor' AS role UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role WHERE d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) ` - for _, id := range query.DashboardIds { - params = append(params, id) - } + for _, id := range query.DashboardIds { + params = append(params, id) + } - sql += ` AND + sql += ` AND d.org_id = ? AND ( (d.has_acl = ? AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) @@ -556,48 +396,53 @@ func GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashbo ) group by d.id order by d.id asc` - params = append(params, query.OrgId) - params = append(params, dialect.BooleanStr(true)) - params = append(params, query.UserId) - params = append(params, query.UserId) - params = append(params, dialect.BooleanStr(false)) + params = append(params, query.OrgId) + params = append(params, dialect.BooleanStr(true)) + params = append(params, query.UserId) + params = append(params, query.UserId) + params = append(params, dialect.BooleanStr(false)) - err := x.SQL(sql, params...).Find(&query.Result) + err := dbSession.SQL(sql, params...).Find(&query.Result) - for _, p := range query.Result { - p.PermissionName = p.Permission.String() - } + for _, p := range query.Result { + p.PermissionName = p.Permission.String() + } - return err + return err + }) } -func GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { - var dashboards = make([]*models.Dashboard, 0) - whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false) +func (ss *SQLStore) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var dashboards = make([]*models.Dashboard, 0) + whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false) - err := x.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards) - query.Result = dashboards - return err + err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards) + query.Result = dashboards + return err + }) } type DashboardSlugDTO struct { Slug string } -func GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { - var rawSQL = `SELECT slug from dashboard WHERE Id=?` - var slug = DashboardSlugDTO{} +func (ss *SQLStore) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT slug from dashboard WHERE Id=?` + var slug = DashboardSlugDTO{} - exists, err := x.SQL(rawSQL, query.Id).Get(&slug) + exists, err := dbSession.SQL(rawSQL, query.Id).Get(&slug) - if err != nil { - return err - } else if !exists { - return models.ErrDashboardNotFound - } + if err != nil { + return err + } else if !exists { + return models.ErrDashboardNotFound + } - query.Result = slug.Slug - return nil + query.Result = slug.Slug + return nil + }) } func (ss *SQLStore) GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error { @@ -619,158 +464,9 @@ func (ss *SQLStore) GetDashboardUIDById(ctx context.Context, query *models.GetDa }) } -func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, dash *models.Dashboard, overwrite bool) (bool, error) { - dashWithIdExists := false - isParentFolderChanged := false - var existingById models.Dashboard - - if dash.Id > 0 { - var err error - dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById) - if err != nil { - return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by ID failed: %w", err) - } - - if !dashWithIdExists { - return isParentFolderChanged, models.ErrDashboardNotFound - } - - if dash.Uid == "" { - dash.SetUid(existingById.Uid) - } - } - - dashWithUidExists := false - var existingByUid models.Dashboard - - if dash.Uid != "" { - var err error - dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid) - if err != nil { - return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by UID failed: %w", err) - } - } - - if dash.FolderId > 0 { - var existingFolder models.Dashboard - folderExists, err := sess.Where("org_id=? AND id=? AND is_folder=?", dash.OrgId, dash.FolderId, - dialect.BooleanStr(true)).Get(&existingFolder) - if err != nil { - return isParentFolderChanged, fmt.Errorf("SQL query for folder failed: %w", err) - } - - if !folderExists { - return isParentFolderChanged, models.ErrDashboardFolderNotFound - } - } - - if !dashWithIdExists && !dashWithUidExists { - return isParentFolderChanged, nil - } - - if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id { - return isParentFolderChanged, models.ErrDashboardWithSameUIDExists - } - - existing := existingById - - if !dashWithIdExists && dashWithUidExists { - dash.SetId(existingByUid.Id) - dash.SetUid(existingByUid.Uid) - existing = existingByUid - - if !dash.IsFolder { - isParentFolderChanged = true - } - } - - if (existing.IsFolder && !dash.IsFolder) || - (!existing.IsFolder && dash.IsFolder) { - return isParentFolderChanged, models.ErrDashboardTypeMismatch - } - - if !dash.IsFolder && dash.FolderId != existing.FolderId { - isParentFolderChanged = true - } - - // check for is someone else has written in between - if dash.Version != existing.Version { - if overwrite { - dash.SetVersion(existing.Version) - } else { - return isParentFolderChanged, models.ErrDashboardVersionMismatch - } - } - - // do not allow plugin dashboard updates without overwrite flag - if existing.PluginId != "" && !overwrite { - return isParentFolderChanged, models.UpdatePluginDashboardError{PluginId: existing.PluginId} - } - - return isParentFolderChanged, nil -} - -func getExistingDashboardByTitleAndFolder(sess *DBSession, dash *models.Dashboard, overwrite, - isParentFolderChanged bool) (bool, error) { - var existing models.Dashboard - exists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, - dialect.BooleanStr(true), dash.FolderId).Get(&existing) - if err != nil { - return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by org ID or folder ID failed: %w", err) - } - - if exists && dash.Id != existing.Id { - if existing.IsFolder && !dash.IsFolder { - return isParentFolderChanged, models.ErrDashboardWithSameNameAsFolder - } - - if !existing.IsFolder && dash.IsFolder { - return isParentFolderChanged, models.ErrDashboardFolderWithSameNameAsDashboard - } - - if !dash.IsFolder && (dash.FolderId != existing.FolderId || dash.Id == 0) { - isParentFolderChanged = true - } - - if overwrite { - dash.SetId(existing.Id) - dash.SetUid(existing.Uid) - dash.SetVersion(existing.Version) - } else { - return isParentFolderChanged, models.ErrDashboardWithSameNameInFolderExists - } - } - - return isParentFolderChanged, nil -} - -func (ss *SQLStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) { - isParentFolderChanged := false - err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error { - var err error - isParentFolderChanged, err = getExistingDashboardByIdOrUidForUpdate(sess, dashboard, overwrite) - if err != nil { - return err - } - - isParentFolderChanged, err = getExistingDashboardByTitleAndFolder(sess, dashboard, overwrite, - isParentFolderChanged) - if err != nil { - return err - } - - return nil - }) - if err != nil { - return false, err - } - - return isParentFolderChanged, nil -} - // HasEditPermissionInFolders validates that an user have access to a certain folder func (ss *SQLStore) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error { - return withDbSession(ctx, x, func(dbSession *DBSession) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { if query.SignedInUser.HasRole(models.ROLE_EDITOR) { query.Result = true return nil @@ -796,26 +492,28 @@ func (ss *SQLStore) HasEditPermissionInFolders(ctx context.Context, query *model }) } -func HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { - if query.SignedInUser.HasRole(models.ROLE_ADMIN) { - query.Result = true +func (ss *SQLStore) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + if query.SignedInUser.HasRole(models.ROLE_ADMIN) { + query.Result = true + return nil + } + + builder := &SQLBuilder{} + builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgId, dialect.BooleanStr(true)) + builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN) + + type folderCount struct { + Count int64 + } + + resp := make([]*folderCount, 0) + if err := dbSession.SQL(builder.GetSQLString(), builder.params...).Find(&resp); err != nil { + return err + } + + query.Result = len(resp) > 0 && resp[0].Count > 0 + return nil - } - - builder := &SQLBuilder{} - builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgId, dialect.BooleanStr(true)) - builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN) - - type folderCount struct { - Count int64 - } - - resp := make([]*folderCount, 0) - if err := x.SQL(builder.GetSQLString(), builder.params...).Find(&resp); err != nil { - return err - } - - query.Result = len(resp) > 0 && resp[0].Count > 0 - - return nil + }) } diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go index 15a032cdecb..bcc04b2a17a 100644 --- a/pkg/services/sqlstore/dashboard_acl.go +++ b/pkg/services/sqlstore/dashboard_acl.go @@ -2,7 +2,6 @@ package sqlstore import ( "context" - "fmt" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" @@ -12,40 +11,6 @@ func (ss *SQLStore) addDashboardACLQueryAndCommandHandlers() { bus.AddHandler("sql", ss.GetDashboardAclInfoList) } -func (ss *SQLStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error { - return ss.UpdateDashboardACLCtx(ctx, dashboardID, items) -} - -func (ss *SQLStore) UpdateDashboardACLCtx(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - // delete existing items - _, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", dashboardID) - if err != nil { - return fmt.Errorf("deleting from dashboard_acl failed: %w", err) - } - - for _, item := range items { - if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { - return models.ErrDashboardAclInfoMissing - } - - if item.DashboardID == 0 { - return models.ErrDashboardPermissionDashboardEmpty - } - - sess.Nullable("user_id", "team_id") - if _, err := sess.Insert(item); err != nil { - return err - } - } - - // Update dashboard HasAcl flag - dashboard := models.Dashboard{HasAcl: true} - _, err = sess.Cols("has_acl").Where("id=?", dashboardID).Update(&dashboard) - return err - }) -} - // GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three // different places. // 1) Permissions for the dashboard diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go index 053f5ad74c8..cc46473caa3 100644 --- a/pkg/services/sqlstore/dashboard_provisioning.go +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -9,7 +9,6 @@ import ( ) func (ss *SQLStore) addDashboardProvisioningQueryAndCommandHandlers() { - bus.AddHandler("sql", UnprovisionDashboard) bus.AddHandler("sql", ss.DeleteOrphanedProvisionedDashboards) } @@ -20,97 +19,6 @@ type DashboardExtras struct { Value string } -func (ss *SQLStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { - var data models.DashboardProvisioning - exists, err := x.Where("dashboard_id = ?", dashboardID).Get(&data) - if err != nil { - return nil, err - } - if exists { - return &data, nil - } - return nil, nil -} - -func (ss *SQLStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { - var provisionedDashboard models.DashboardProvisioning - err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error { - var dashboard models.Dashboard - exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard) - if err != nil { - return err - } - if !exists { - return models. - ErrDashboardNotFound - } - exists, err = sess.Where("dashboard_id = ?", dashboard.Id).Get(&provisionedDashboard) - if err != nil { - return err - } - if !exists { - return models.ErrProvisionedDashboardNotFound - } - return nil - }) - return &provisionedDashboard, err -} - -func (ss *SQLStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, - provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { - err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error { - if err := saveDashboard(sess, &cmd); err != nil { - return err - } - - if provisioning.Updated == 0 { - provisioning.Updated = cmd.Result.Updated.Unix() - } - - return saveProvisionedData(sess, provisioning, cmd.Result) - }) - - return cmd.Result, err -} - -func saveProvisionedData(sess *DBSession, provisioning *models.DashboardProvisioning, dashboard *models.Dashboard) error { - result := &models.DashboardProvisioning{} - - exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, provisioning.Name).Get(result) - if err != nil { - return err - } - - provisioning.Id = result.Id - provisioning.DashboardId = dashboard.Id - - if exist { - _, err = sess.ID(result.Id).Update(provisioning) - } else { - _, err = sess.Insert(provisioning) - } - - return err -} - -func (ss *SQLStore) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { - var result []*models.DashboardProvisioning - if err := ss.engine.Where("name = ?", name).Find(&result); err != nil { - return nil, err - } - - return result, nil -} - -// UnprovisionDashboard removes row in dashboard_provisioning for the dashboard making it seem as if manually created. -// The dashboard will still have `created_by = -1` to see it was not created by any particular user. -func UnprovisionDashboard(ctx context.Context, cmd *models.UnprovisionDashboardCommand) error { - if _, err := x.Where("dashboard_id = ?", cmd.Id).Delete(&models.DashboardProvisioning{}); err != nil { - return err - } - return nil -} - func (ss *SQLStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { var result []*models.DashboardProvisioning diff --git a/pkg/services/sqlstore/dashboard_thumbs.go b/pkg/services/sqlstore/dashboard_thumbs.go index 6b819ca46a2..907d7928991 100644 --- a/pkg/services/sqlstore/dashboard_thumbs.go +++ b/pkg/services/sqlstore/dashboard_thumbs.go @@ -81,6 +81,20 @@ func (ss *SQLStore) UpdateThumbnailState(ctx context.Context, cmd *models.Update return err } +func (ss *SQLStore) FindThumbnailCount(ctx context.Context, cmd *models.FindDashboardThumbnailCountCommand) (int64, error) { + err := ss.WithDbSession(ctx, func(sess *DBSession) error { + count, err := sess.Count(&models.DashboardThumbnail{}) + if err != nil { + return err + } + + cmd.Result = count + return nil + }) + + return cmd.Result, err +} + func (ss *SQLStore) FindDashboardsWithStaleThumbnails(ctx context.Context, cmd *models.FindDashboardsWithStaleThumbnailsCommand) ([]*models.DashboardWithStaleThumbnail, error) { err := ss.WithDbSession(ctx, func(sess *DBSession) error { sess.Table("dashboard") diff --git a/pkg/services/sqlstore/dashboard_thumbs_test.go b/pkg/services/sqlstore/dashboard_thumbs_test.go index 5cba1949f64..4fc9395cd57 100644 --- a/pkg/services/sqlstore/dashboard_thumbs_test.go +++ b/pkg/services/sqlstore/dashboard_thumbs_test.go @@ -185,6 +185,23 @@ func TestSqlStorage(t *testing.T) { require.Len(t, res, 1) require.Equal(t, dash.Id, res[0].Id) }) + + t.Run("Should count all dashboard thumbnails", func(t *testing.T) { + setup() + dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp") + upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, 1) + dash2 := insertTestDashboard(t, sqlStore, "test dash 23", 2, savedFolder.Id, false, "prod", "webapp") + upsertTestDashboardThumbnail(t, sqlStore, dash2.Uid, dash2.OrgId, 1) + + updateTestDashboard(t, sqlStore, dash, map[string]interface{}{ + "tags": "different-tag", + }) + + cmd := models.FindDashboardThumbnailCountCommand{} + res, err := sqlStore.FindThumbnailCount(context.Background(), &cmd) + require.NoError(t, err) + require.Equal(t, res, int64(2)) + }) } func getThumbnail(t *testing.T, sqlStore *SQLStore, dashboardUID string, orgId int64) *models.DashboardThumbnail { diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go index 6b9536d1d08..07adedc92ab 100644 --- a/pkg/services/sqlstore/dashboard_version_test.go +++ b/pkg/services/sqlstore/dashboard_version_test.go @@ -7,10 +7,12 @@ import ( "context" "reflect" "testing" + "time" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" ) @@ -19,13 +21,60 @@ func updateTestDashboard(t *testing.T, sqlStore *SQLStore, dashboard *models.Das data["id"] = dashboard.Id - saveCmd := models.SaveDashboardCommand{ + parentVersion := dashboard.Version + + cmd := models.SaveDashboardCommand{ OrgId: dashboard.OrgId, Overwrite: true, Dashboard: simplejson.NewFromAny(data), } - _, err := sqlStore.SaveDashboard(saveCmd) + var dash *models.Dashboard + err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + var existing models.Dashboard + dash = cmd.GetDashboardModel() + dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing) + require.NoError(t, err) + require.True(t, dashWithIdExists) + + if dash.Version != existing.Version { + dash.SetVersion(existing.Version) + dash.Version = existing.Version + } + + dash.SetVersion(dash.Version + 1) + dash.Created = time.Now() + dash.Updated = time.Now() + dash.Id = dashboard.Id + dash.Uid = util.GenerateShortUID() + + _, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash) + return err + }) + require.Nil(t, err) + + err = sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + dashVersion := &models.DashboardVersion{ + DashboardId: dash.Id, + ParentVersion: parentVersion, + RestoredFrom: cmd.RestoredFrom, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, + } + + if affectedRows, err := sess.Insert(dashVersion); err != nil { + return err + } else if affectedRows == 0 { + return models.ErrDashboardNotFound + } + + return nil + }) + + require.NoError(t, err) } func TestGetDashboardVersion(t *testing.T) { @@ -94,7 +143,6 @@ func TestGetDashboardVersions(t *testing.T) { updateTestDashboard(t, sqlStore, savedDash, map[string]interface{}{ "tags": "different-tag", }) - query := models.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1} err := sqlStore.GetDashboardVersions(context.Background(), &query) diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 0ad11bb52f0..6ec9c94a193 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -2,6 +2,7 @@ package sqlstore import ( "context" + "errors" "fmt" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util/errutil" "xorm.io/xorm" ) @@ -20,26 +22,30 @@ func (ss *SQLStore) GetDataSource(ctx context.Context, query *models.GetDataSour metrics.MDBDataSourceQueryByID.Inc() return ss.WithDbSession(ctx, func(sess *DBSession) error { - if query.OrgId == 0 || (query.Id == 0 && len(query.Name) == 0 && len(query.Uid) == 0) { - return models.ErrDataSourceIdentifierNotSet - } - - datasource := &models.DataSource{Name: query.Name, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid} - has, err := sess.Get(datasource) - - if err != nil { - sqlog.Error("Failed getting data source", "err", err, "uid", query.Uid, "id", query.Id, "name", query.Name, "orgId", query.OrgId) - return err - } else if !has { - return models.ErrDataSourceNotFound - } - - query.Result = datasource - - return nil + return ss.getDataSource(ctx, query, sess) }) } +func (ss *SQLStore) getDataSource(ctx context.Context, query *models.GetDataSourceQuery, sess *DBSession) error { + if query.OrgId == 0 || (query.Id == 0 && len(query.Name) == 0 && len(query.Uid) == 0) { + return models.ErrDataSourceIdentifierNotSet + } + + datasource := &models.DataSource{Name: query.Name, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid} + has, err := sess.Get(datasource) + + if err != nil { + sqlog.Error("Failed getting data source", "err", err, "uid", query.Uid, "id", query.Id, "name", query.Name, "orgId", query.OrgId) + return err + } else if !has { + return models.ErrDataSourceNotFound + } + + query.Result = datasource + + return nil +} + func (ss *SQLStore) GetDataSources(ctx context.Context, query *models.GetDataSourcesQuery) error { var sess *xorm.Session return ss.WithDbSession(ctx, func(dbSess *DBSession) error { @@ -82,32 +88,34 @@ func (ss *SQLStore) GetDefaultDataSource(ctx context.Context, query *models.GetD } // DeleteDataSource removes a datasource by org_id as well as either uid (preferred), id, or name -// and is added to the bus. +// and is added to the bus. It also removes permissions related to the datasource. func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { - params := make([]interface{}, 0) - - makeQuery := func(sql string, p ...interface{}) { - params = append(params, sql) - params = append(params, p...) - } - - switch { - case cmd.OrgID == 0: - return models.ErrDataSourceIdentifierNotSet - case cmd.UID != "": - makeQuery("DELETE FROM data_source WHERE uid=? and org_id=?", cmd.UID, cmd.OrgID) - case cmd.ID != 0: - makeQuery("DELETE FROM data_source WHERE id=? and org_id=?", cmd.ID, cmd.OrgID) - case cmd.Name != "": - makeQuery("DELETE FROM data_source WHERE name=? and org_id=?", cmd.Name, cmd.OrgID) - default: - return models.ErrDataSourceIdentifierNotSet - } - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - result, err := sess.Exec(params...) - cmd.DeletedDatasourcesCount, _ = result.RowsAffected() + dsQuery := &models.GetDataSourceQuery{Id: cmd.ID, Uid: cmd.UID, Name: cmd.Name, OrgId: cmd.OrgID} + errGettingDS := ss.getDataSource(ctx, dsQuery, sess) + if errGettingDS != nil && !errors.Is(errGettingDS, models.ErrDataSourceNotFound) { + return errGettingDS + } + + ds := dsQuery.Result + if ds != nil { + // Delete the data source + result, err := sess.Exec("DELETE FROM data_source WHERE org_id=? AND id=?", ds.OrgId, ds.Id) + if err != nil { + return err + } + + cmd.DeletedDatasourcesCount, _ = result.RowsAffected() + + // Remove associated AccessControl permissions + if _, errDeletingPerms := sess.Exec("DELETE FROM permission WHERE scope=?", + ac.Scope("datasources", "id", fmt.Sprint(dsQuery.Result.Id))); errDeletingPerms != nil { + return errDeletingPerms + } + } + + // Publish data source deletion event sess.publishAfterCommit(&events.DataSourceDeleted{ Timestamp: time.Now(), Name: cmd.Name, @@ -116,7 +124,7 @@ func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteData OrgID: cmd.OrgID, }) - return err + return nil }) } diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index b53a7703f56..46e36620f01 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -6,6 +6,7 @@ package sqlstore import ( "context" "errors" + "fmt" "strconv" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -226,8 +228,10 @@ func TestDataAccess(t *testing.T) { sqlStore := InitTestDB(t) ds := initDatasource(sqlStore) - err := sqlStore.DeleteDataSource(context.Background(), &models.DeleteDataSourceCommand{ID: ds.Id, OrgID: 123123}) + err := sqlStore.DeleteDataSource(context.Background(), + &models.DeleteDataSourceCommand{ID: ds.Id, OrgID: 123123}) require.NoError(t, err) + query := models.GetDataSourcesQuery{OrgId: 10} err = sqlStore.GetDataSources(context.Background(), &query) require.NoError(t, err) @@ -246,7 +250,8 @@ func TestDataAccess(t *testing.T) { return nil }) - err := sqlStore.DeleteDataSource(context.Background(), &models.DeleteDataSourceCommand{ID: ds.Id, UID: "nisse-uid", Name: "nisse", OrgID: 123123}) + err := sqlStore.DeleteDataSource(context.Background(), + &models.DeleteDataSourceCommand{ID: ds.Id, UID: "nisse-uid", Name: "nisse", OrgID: int64(123123)}) require.NoError(t, err) require.Eventually(t, func() bool { @@ -273,6 +278,42 @@ func TestDataAccess(t *testing.T) { require.Equal(t, 0, len(query.Result)) }) + t.Run("DeleteDataSourceAccessControlPermissions", func(t *testing.T) { + sqlStore := InitTestDB(t) + ds := initDatasource(sqlStore) + + // Init associated permission + errAddPermissions := sqlStore.WithTransactionalDbSession(context.TODO(), func(sess *DBSession) error { + _, err := sess.Table("permission").Insert(ac.Permission{ + RoleID: 1, + Action: "datasources:read", + Scope: ac.Scope("datasources", "id", fmt.Sprintf("%d", ds.Id)), + Updated: time.Now(), + Created: time.Now(), + }) + return err + }) + require.NoError(t, errAddPermissions) + query := models.GetDataSourcesQuery{OrgId: 10} + + errDeletingDS := sqlStore.DeleteDataSource(context.Background(), + &models.DeleteDataSourceCommand{Name: ds.Name, OrgID: ds.OrgId}, + ) + require.NoError(t, errDeletingDS) + + // Check associated permission + permCount := int64(0) + errGetPermissions := sqlStore.WithTransactionalDbSession(context.TODO(), func(sess *DBSession) error { + var err error + permCount, err = sess.Table("permission").Count() + return err + }) + require.NoError(t, errGetPermissions) + require.Zero(t, permCount, "permissions associated to the data source should have been removed") + + require.Equal(t, 0, len(query.Result)) + }) + t.Run("GetDataSources", func(t *testing.T) { t.Run("Number of data sources returned limited to 6 per organization", func(t *testing.T) { sqlStore := InitTestDB(t) diff --git a/pkg/services/sqlstore/health.go b/pkg/services/sqlstore/health.go index a0a1b4551fc..9e1e98728cc 100644 --- a/pkg/services/sqlstore/health.go +++ b/pkg/services/sqlstore/health.go @@ -9,7 +9,7 @@ import ( // GetDBHealthQuery executes a query to check // the availability of the database. func (ss *SQLStore) GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error { - return withDbSession(ctx, x, func(session *DBSession) error { + return ss.WithDbSession(ctx, func(session *DBSession) error { _, err := session.Exec("SELECT 1") return err }) diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 3ee4d111954..83a26cc52df 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -65,7 +65,7 @@ func TestMigrationLock(t *testing.T) { t.Skip() } - testDB := getTestDB(dbType) + testDB := getTestDB(t, dbType) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -154,7 +154,7 @@ func TestMigrationLock(t *testing.T) { func TestMigratorLocking(t *testing.T) { dbType := getDBType() - testDB := getTestDB(dbType) + testDB := getTestDB(t, dbType) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -172,7 +172,7 @@ func TestMigratorLocking(t *testing.T) { i := i // capture i variable t.Run(fmt.Sprintf("run migration %d", i), func(t *testing.T) { t.Parallel() - err = mg.Start(true, 0) + err := mg.Start(true, 0) if err != nil { if errors.Is(err, ErrMigratorIsLocked) { atomic.AddInt64(&errorNum, 1) @@ -191,7 +191,7 @@ func TestDatabaseLocking(t *testing.T) { t.Skip() } - testDB := getTestDB(dbType) + testDB := getTestDB(t, dbType) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -281,14 +281,24 @@ func getDBType() string { return dbType } -func getTestDB(dbType string) sqlutil.TestDB { +func getTestDB(t *testing.T, dbType string) sqlutil.TestDB { switch dbType { case "mysql": return sqlutil.MySQLTestDB() case "postgres": return sqlutil.PostgresTestDB() default: - return sqlutil.SQLite3TestDB() + f, err := os.CreateTemp(".", "grafana-test-db-") + require.NoError(t, err) + t.Cleanup(func() { + err := os.Remove(f.Name()) + require.NoError(t, err) + }) + + return sqlutil.TestDB{ + DriverName: "sqlite3", + ConnStr: f.Name(), + } } } diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index 7d872119c4d..e3f42b6057a 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -327,7 +327,7 @@ func AddAlertAdminConfigMigrations(mg *migrator.Migrator) { mg.AddMigration("create_ngalert_configuration_table", migrator.NewAddTableMigration(adminConfiguration)) mg.AddMigration("add index in ngalert_configuration on org_id column", migrator.NewAddIndexMigration(adminConfiguration, adminConfiguration.Indices[0])) mg.AddMigration("add column send_alerts_to in ngalert_configuration", migrator.NewAddColumnMigration(adminConfiguration, &migrator.Column{ - Name: "send_alerts_to", Type: migrator.DB_SmallInt, Nullable: false, + Name: "send_alerts_to", Type: migrator.DB_SmallInt, Nullable: false, Default: "0", })) } diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index 0dfd3063f48..6c33849621c 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -127,11 +127,25 @@ func addUserMigrations(mg *Migrator) { Cols: []string{"login", "email"}, })) + //Service accounts are lightweight users with restricted permissions. They support API keys + //and provisioning and tasks like alarms and reports. + // Issues in this migration: is_service_account should be nullable mg.AddMigration("Add is_service_account column to user", NewAddColumnMigration(userV2, &Column{ Name: "is_service_account", Type: DB_Bool, Nullable: false, Default: "0", })) + + mg.AddMigration("Update is_service_account column to nullable", + NewRawSQLMigration(""). + SQLite(migSQLITEisServiceAccountNullable). + Postgres("ALTER TABLE `user` ALTER COLUMN is_service_account DROP NOT NULL;"). + Mysql("ALTER TABLE user MODIFY is_service_account BOOLEAN DEFAULT 0;")) } +const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0; +UPDATE user SET tmp_service_account = is_service_account; +ALTER TABLE user DROP COLUMN is_service_account; +ALTER TABLE user RENAME COLUMN tmp_service_account TO is_service_account;` + type AddMissingUserSaltAndRandsMigration struct { MigrationBase } diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index b9ee9aaf9ea..58387874d51 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -112,23 +112,7 @@ func (m *SQLStoreMock) DeleteOrg(ctx context.Context, cmd *models.DeleteOrgComma return m.ExpectedError } -func (m *SQLStoreMock) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) { - return &models.DashboardProvisioning{}, m.ExpectedError -} - -func (m *SQLStoreMock) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { - return nil, m.ExpectedError -} - -func (m *SQLStoreMock) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { - return nil, m.ExpectedError -} - -func (m *SQLStoreMock) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) { - return nil, m.ExpectedError -} - -func (m *SQLStoreMock) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { +func (m SQLStoreMock) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error { return m.ExpectedError } @@ -382,16 +366,7 @@ func (m *SQLStoreMock) DeleteExpiredVersions(ctx context.Context, cmd *models.De return m.ExpectedError } -func (m *SQLStoreMock) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) UpdateDashboardACLCtx(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetDashboardAclInfoList(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error { - query.Result = m.ExpectedDashboardAclInfoList +func (m SQLStoreMock) GetDashboardAclInfoList(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error { return m.ExpectedError } @@ -433,11 +408,7 @@ func (m *SQLStoreMock) HandleAlertsQuery(ctx context.Context, query *models.GetA return m.ExpectedError } -func (m *SQLStoreMock) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error { +func (m SQLStoreMock) SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error { return m.ExpectedError } @@ -484,18 +455,14 @@ func (m *SQLStoreMock) GetDashboard(ctx context.Context, query *models.GetDashbo return m.ExpectedError } +func (m SQLStoreMock) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error { + return m.ExpectedError +} + func (m *SQLStoreMock) GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error { return nil // TODO: Implement } -func (m *SQLStoreMock) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { - return nil, m.ExpectedError -} - -func (m *SQLStoreMock) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error { - return m.ExpectedError -} - func (m *SQLStoreMock) DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error { cmd.Id = m.ExpectedDashboard.Id cmd.OrgId = m.ExpectedDashboard.OrgId @@ -510,11 +477,7 @@ func (m *SQLStoreMock) GetDashboardUIDById(ctx context.Context, query *models.Ge return m.ExpectedError } -func (m *SQLStoreMock) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) { - return false, nil -} - -func (m *SQLStoreMock) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error { +func (m SQLStoreMock) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error { query.Result = m.ExpectedDatasource return m.ExpectedError } @@ -674,3 +637,19 @@ func (m *SQLStoreMock) SearchOrgs(ctx context.Context, query *models.SearchOrgsQ query.Result = m.ExpectedSearchOrgList return m.ExpectedError } + +func (m *SQLStoreMock) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { + return m.ExpectedError +} diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 95f3056e35f..6565e1eefc2 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -9,8 +9,10 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" ) @@ -165,6 +167,7 @@ func TestAccountDataAccess(t *testing.T) { err := SearchUsers(context.Background(), &query) require.NoError(t, err) + require.Len(t, query.Result.Users, 2) require.Equal(t, query.Result.Users[0].Email, "ac1@test.com") require.Equal(t, query.Result.Users[1].Email, "ac2@test.com") }) @@ -329,12 +332,12 @@ func TestAccountDataAccess(t *testing.T) { dash1 := insertTestDashboard(t, sqlStore, "1 test dash", ac1.OrgId, 0, false, "prod", "webapp") dash2 := insertTestDashboard(t, sqlStore, "2 test dash", ac3.OrgId, 0, false, "prod", "webapp") - err = testHelperUpdateDashboardAcl(t, sqlStore, dash1.Id, models.DashboardAcl{ + err = updateDashboardAcl(t, sqlStore, dash1.Id, &models.DashboardAcl{ DashboardID: dash1.Id, OrgID: ac1.OrgId, UserID: ac3.Id, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) - err = testHelperUpdateDashboardAcl(t, sqlStore, dash2.Id, models.DashboardAcl{ + err = updateDashboardAcl(t, sqlStore, dash2.Id, &models.DashboardAcl{ DashboardID: dash2.Id, OrgID: ac3.OrgId, UserID: ac3.Id, Permission: models.PERMISSION_EDIT, }) require.NoError(t, err) @@ -370,16 +373,92 @@ func TestAccountDataAccess(t *testing.T) { }) } -func testHelperUpdateDashboardAcl(t *testing.T, sqlStore *SQLStore, dashboardID int64, - items ...models.DashboardAcl) error { +//TODO: Use FakeDashboardStore when org has its own service +func insertTestDashboard(t *testing.T, sqlStore *SQLStore, title string, orgId int64, + folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + + var dash *models.Dashboard + err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + dash = cmd.GetDashboardModel() + dash.SetVersion(1) + dash.Created = time.Now() + dash.Updated = time.Now() + dash.Uid = util.GenerateShortUID() + _, err := sess.Insert(dash) + return err + }) + + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + + err = sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + dashVersion := &models.DashboardVersion{ + DashboardId: dash.Id, + ParentVersion: dash.Version, + RestoredFrom: cmd.RestoredFrom, + Version: dash.Version, + Created: time.Now(), + CreatedBy: dash.UpdatedBy, + Message: cmd.Message, + Data: dash.Data, + } + + if affectedRows, err := sess.Insert(dashVersion); err != nil { + return err + } else if affectedRows == 0 { + return models.ErrDashboardNotFound + } + + return nil + }) + + return dash +} + +//TODO: Use FakeDashboardStore when org has its own service +func updateDashboardAcl(t *testing.T, sqlStore *SQLStore, dashboardID int64, items ...*models.DashboardAcl) error { t.Helper() - var itemPtrs []*models.DashboardAcl - for _, it := range items { - item := it - item.Created = time.Now() - item.Updated = time.Now() - itemPtrs = append(itemPtrs, &item) - } - return sqlStore.UpdateDashboardACL(context.Background(), dashboardID, itemPtrs) + err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error { + _, err := sess.Exec("DELETE FROM dashboard_acl WHERE dashboard_id=?", dashboardID) + if err != nil { + return fmt.Errorf("deleting from dashboard_acl failed: %w", err) + } + + for _, item := range items { + item.Created = time.Now() + item.Updated = time.Now() + if item.UserID == 0 && item.TeamID == 0 && (item.Role == nil || !item.Role.IsValid()) { + return models.ErrDashboardAclInfoMissing + } + + if item.DashboardID == 0 { + return models.ErrDashboardPermissionDashboardEmpty + } + + sess.Nullable("user_id", "team_id") + if _, err := sess.Insert(item); err != nil { + return err + } + } + + // Update dashboard HasAcl flag + dashboard := models.Dashboard{HasAcl: true} + _, err = sess.Cols("has_acl").Where("id=?", dashboardID).Update(&dashboard) + return err + }) + return err } diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index f66cc1d373f..1d17b593d50 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -1,19 +1,16 @@ -//go:build integration -// +build integration - // package search_test contains integration tests for search package searchstore_test import ( "context" "testing" - "time" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" + "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -171,11 +168,27 @@ func createDashboards(t *testing.T, db *sqlstore.SQLStore, startID, endID int, o "version": 0 }`)) require.NoError(t, err) - dash, err := db.SaveDashboard(models.SaveDashboardCommand{ - Dashboard: dashboard, - UserId: 1, - OrgId: orgID, - UpdatedAt: time.Now(), + + var dash *models.Dashboard + err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + dash = models.NewDashboardFromJson(dashboard) + dash.OrgId = orgID + dash.Uid = util.GenerateShortUID() + dash.CreatedBy = 1 + dash.UpdatedBy = 1 + _, err := sess.Insert(dash) + require.NoError(t, err) + + tags := dash.GetTags() + if len(tags) > 0 { + for _, tag := range tags { + if _, err := sess.Insert(&sqlstore.DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil { + return err + } + } + } + + return nil }) require.NoError(t, err) diff --git a/pkg/services/sqlstore/sqlbuilder_test.go b/pkg/services/sqlstore/sqlbuilder_test.go index 7ef90102343..1b3065860f6 100644 --- a/pkg/services/sqlstore/sqlbuilder_test.go +++ b/pkg/services/sqlstore/sqlbuilder_test.go @@ -244,7 +244,7 @@ func createDummyDashboard(t *testing.T, sqlStore *SQLStore, dashboardProps Dashb saveDashboardCmd.OrgId = 1 } - dash, err := sqlStore.SaveDashboard(saveDashboardCmd) + dash := insertTestDashboard(t, sqlStore, "", saveDashboardCmd.OrgId, 0, false, nil) require.NoError(t, err) t.Logf("Created dashboard with ID %d and org ID %d\n", dash.Id, dash.OrgId) @@ -287,7 +287,7 @@ func createDummyACL(t *testing.T, sqlStore *SQLStore, dashboardPermission *Dashb acl.Role = &dashboardPermission.Role } - err := sqlStore.UpdateDashboardACL(context.Background(), dashboardID, []*models.DashboardAcl{acl}) + err := updateDashboardAcl(t, sqlStore, dashboardID, acl) require.NoError(t, err) if user != nil { return user.Id diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index b86c1eae856..b83ee8cbba1 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -56,7 +56,7 @@ type SQLStore struct { tracer tracing.Tracer } -func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer, features *featuremgmt.FeatureManager) (*SQLStore, error) { +func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) { // This change will make xorm use an empty default schema for postgres and // by that mimic the functionality of how it was functioning before // xorm's changes above. @@ -66,7 +66,7 @@ func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus return nil, err } - if err := s.Migrate(features.IsEnabled(featuremgmt.FlagMigrationLocking)); err != nil { + if err := s.Migrate(cfg.IsFeatureToggleEnabled(featuremgmt.FlagMigrationLocking)); err != nil { return nil, err } diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 0a0a8da7fe4..cd1de67f128 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -9,12 +9,9 @@ import ( "github.com/grafana/grafana/pkg/models" ) -func init() { - bus.AddHandler("sql", GetSystemUserCountStats) -} - func (ss *SQLStore) addStatsQueryAndCommandHandlers() { bus.AddHandler("sql", ss.GetAdminStats) + bus.AddHandler("sql", ss.GetSystemUserCountStats) bus.AddHandler("sql", ss.GetAlertNotifiersUsageStats) bus.AddHandler("sql", ss.GetDataSourceAccessStats) bus.AddHandler("sql", ss.GetDataSourceStats) @@ -25,50 +22,57 @@ const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 func (ss *SQLStore) GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error { - var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type` - query.Result = make([]*models.NotifierUsageStats, 0) - err := x.SQL(rawSQL).Find(&query.Result) - return err + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type` + query.Result = make([]*models.NotifierUsageStats, 0) + err := dbSession.SQL(rawSQL).Find(&query.Result) + return err + }) } func (ss *SQLStore) GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error { - var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type` - query.Result = make([]*models.DataSourceStats, 0) - err := x.SQL(rawSQL).Find(&query.Result) - return err + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type` + query.Result = make([]*models.DataSourceStats, 0) + err := dbSession.SQL(rawSQL).Find(&query.Result) + return err + }) } func (ss *SQLStore) GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error { - var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access` - query.Result = make([]*models.DataSourceAccessStats, 0) - err := x.SQL(rawSQL).Find(&query.Result) - return err + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access` + query.Result = make([]*models.DataSourceAccessStats, 0) + err := dbSession.SQL(rawSQL).Find(&query.Result) + return err + }) } func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error { - sb := &SQLBuilder{} - sb.Write("SELECT ") - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`) + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + sb := &SQLBuilder{} + sb.Write("SELECT ") + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`) - now := time.Now() - activeUserDeadlineDate := now.Add(-activeUserTimeLimit) - sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate) + now := time.Now() + activeUserDeadlineDate := now.Add(-activeUserTimeLimit) + sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate) - dailyActiveUserDeadlineDate := now.Add(-dailyActiveUserTimeLimit) - sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS daily_active_users,`, dailyActiveUserDeadlineDate) + dailyActiveUserDeadlineDate := now.Add(-dailyActiveUserTimeLimit) + sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS daily_active_users,`, dailyActiveUserDeadlineDate) - monthlyActiveUserDeadlineDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate) + monthlyActiveUserDeadlineDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false)) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false)) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) - sb.Write(`( + sb.Write(`( SELECT COUNT(acl.id) FROM `+dialect.Quote("dashboard_acl")+` AS acl INNER JOIN `+dialect.Quote("dashboard")+` AS d @@ -76,7 +80,7 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS WHERE d.is_folder = ? ) AS dashboard_permissions,`, dialect.BooleanStr(false)) - sb.Write(`( + sb.Write(`( SELECT COUNT(acl.id) FROM `+dialect.Quote("dashboard_acl")+` AS acl INNER JOIN `+dialect.Quote("dashboard")+` AS d @@ -84,40 +88,41 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS WHERE d.is_folder = ? ) AS folder_permissions,`, dialect.BooleanStr(true)) - sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) - sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("annotation") + `) AS annotations,`) - 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) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("annotation") + `) AS annotations,`) + 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) - sb.Write(roleCounterSQL(ctx)) + sb.Write(ss.roleCounterSQL(ctx)) - var stats models.SystemStats - _, err := x.SQL(sb.GetSQLString(), sb.params...).Get(&stats) - if err != nil { - return err - } + var stats models.SystemStats + _, err := dbSession.SQL(sb.GetSQLString(), sb.params...).Get(&stats) + if err != nil { + return err + } - query.Result = &stats + query.Result = &stats - return nil + return nil + }) } -func roleCounterSQL(ctx context.Context) string { +func (ss *SQLStore) roleCounterSQL(ctx context.Context) string { const roleCounterTimeout = 20 * time.Second ctx, cancel := context.WithTimeout(ctx, roleCounterTimeout) defer cancel() - _ = updateUserRoleCountsIfNecessary(ctx, false) + _ = ss.updateUserRoleCountsIfNecessary(ctx, false) sqlQuery := strconv.FormatInt(userStatsCache.total.Admins, 10) + ` AS admins, ` + strconv.FormatInt(userStatsCache.total.Editors, 10) + ` AS editors, ` + @@ -145,12 +150,13 @@ func viewersPermissionsCounterSQL(statName string, isFolder bool, permission mod } func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error { - now := time.Now() - activeEndDate := now.Add(-activeUserTimeLimit) - dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) - monthlyActiveEndDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + now := time.Now() + activeEndDate := now.Add(-activeUserTimeLimit) + dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) + monthlyActiveEndDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - var rawSQL = `SELECT + var rawSQL = `SELECT ( SELECT COUNT(*) FROM ` + dialect.Quote("org") + ` @@ -199,7 +205,7 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE last_seen_at > ? ) AS monthly_active_users, - ` + roleCounterSQL(ctx) + `, + ` + ss.roleCounterSQL(ctx) + `, ( SELECT COUNT(*) FROM ` + dialect.Quote("user_auth_token") + ` WHERE rotated_at > ? @@ -209,18 +215,19 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta FROM ` + dialect.Quote("user_auth_token") + ` WHERE rotated_at > ? ) AS daily_active_sessions` - var stats models.AdminStats - _, err := x.SQL(rawSQL, activeEndDate, dailyActiveEndDate, monthlyActiveEndDate, activeEndDate.Unix(), dailyActiveEndDate.Unix()).Get(&stats) - if err != nil { - return err - } + var stats models.AdminStats + _, err := dbSession.SQL(rawSQL, activeEndDate, dailyActiveEndDate, monthlyActiveEndDate, activeEndDate.Unix(), dailyActiveEndDate.Unix()).Get(&stats) + if err != nil { + return err + } - query.Result = &stats - return nil + query.Result = &stats + return nil + }) } -func GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { - return withDbSession(ctx, x, func(sess *DBSession) error { +func (ss *SQLStore) GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { var rawSQL = `SELECT COUNT(id) AS Count FROM ` + dialect.Quote("user") var stats models.SystemUserCountStats _, err := sess.SQL(rawSQL).Get(&stats) @@ -234,10 +241,10 @@ func GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCou }) } -func updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error { +func (ss *SQLStore) updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error { memoizationPeriod := time.Now().Add(-userStatsCacheLimetime) if forced || userStatsCache.memoized.Before(memoizationPeriod) { - err := updateUserRoleCounts(ctx) + err := ss.updateUserRoleCounts(ctx) if err != nil { return err } @@ -258,8 +265,9 @@ var ( userStatsCacheLimetime = 5 * time.Minute ) -func updateUserRoleCounts(ctx context.Context) error { - query := ` +func (ss *SQLStore) updateUserRoleCounts(ctx context.Context) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + query := ` SELECT role AS bitrole, active, COUNT(role) AS count FROM (SELECT last_seen_at>? AS active, last_seen_at>? AS daily_active, SUM(role) AS role FROM (SELECT @@ -275,42 +283,43 @@ SELECT role AS bitrole, active, COUNT(role) AS count FROM GROUP BY id, last_seen_at) AS t1 GROUP BY active, daily_active, role;` - activeUserDeadline := time.Now().Add(-activeUserTimeLimit) - dailyActiveUserDeadline := time.Now().Add(-dailyActiveUserTimeLimit) + activeUserDeadline := time.Now().Add(-activeUserTimeLimit) + dailyActiveUserDeadline := time.Now().Add(-dailyActiveUserTimeLimit) - type rolebitmap struct { - Active bool - DailyActive bool - Bitrole int64 - Count int64 - } - - bitmap := []rolebitmap{} - err := x.Context(ctx).SQL(query, activeUserDeadline, dailyActiveUserDeadline).Find(&bitmap) - if err != nil { - return err - } - - memo := memoUserStats{memoized: time.Now()} - for _, role := range bitmap { - roletype := models.ROLE_VIEWER - if role.Bitrole&0b100 != 0 { - roletype = models.ROLE_ADMIN - } else if role.Bitrole&0b10 != 0 { - roletype = models.ROLE_EDITOR + type rolebitmap struct { + Active bool + DailyActive bool + Bitrole int64 + Count int64 } - memo.total = addToStats(memo.total, roletype, role.Count) - if role.Active { - memo.active = addToStats(memo.active, roletype, role.Count) + bitmap := []rolebitmap{} + err := dbSession.Context(ctx).SQL(query, activeUserDeadline, dailyActiveUserDeadline).Find(&bitmap) + if err != nil { + return err } - if role.DailyActive { - memo.dailyActive = addToStats(memo.dailyActive, roletype, role.Count) - } - } - userStatsCache = memo - return nil + memo := memoUserStats{memoized: time.Now()} + for _, role := range bitmap { + roletype := models.ROLE_VIEWER + if role.Bitrole&0b100 != 0 { + roletype = models.ROLE_ADMIN + } else if role.Bitrole&0b10 != 0 { + roletype = models.ROLE_EDITOR + } + + memo.total = addToStats(memo.total, roletype, role.Count) + if role.Active { + memo.active = addToStats(memo.active, roletype, role.Count) + } + if role.DailyActive { + memo.dailyActive = addToStats(memo.dailyActive, roletype, role.Count) + } + } + + userStatsCache = memo + return nil + }) } func addToStats(base models.UserStats, role models.RoleType, count int64) models.UserStats { diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index 1100e8fafea..bd0a67c778d 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -32,7 +32,7 @@ func TestStatsDataAccess(t *testing.T) { t.Run("Get system user count stats should not results in error", func(t *testing.T) { query := models.GetSystemUserCountStatsQuery{} - err := GetSystemUserCountStats(context.Background(), &query) + err := sqlStore.GetSystemUserCountStats(context.Background(), &query) assert.NoError(t, err) }) @@ -124,7 +124,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { require.NoError(t, err) // force renewal of user stats - err = updateUserRoleCountsIfNecessary(context.Background(), true) + err = sqlStore.updateUserRoleCountsIfNecessary(context.Background(), true) require.NoError(t, err) // add 1st api key diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index d97b593d1d9..100b3e75f21 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -24,10 +24,6 @@ type Store interface { UpdateOrg(ctx context.Context, cmd *models.UpdateOrgCommand) error UpdateOrgAddress(ctx context.Context, cmd *models.UpdateOrgAddressCommand) error DeleteOrg(ctx context.Context, cmd *models.DeleteOrgCommand) error - GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) - GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) - SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) - GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error @@ -87,8 +83,6 @@ type Store interface { GetDashboardVersion(ctx context.Context, query *models.GetDashboardVersionQuery) error GetDashboardVersions(ctx context.Context, query *models.GetDashboardVersionsQuery) error DeleteExpiredVersions(ctx context.Context, cmd *models.DeleteExpiredVersionsCommand) error - UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error - UpdateDashboardACLCtx(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error GetDashboardAclInfoList(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error CreatePlaylist(ctx context.Context, cmd *models.CreatePlaylistCommand) error UpdatePlaylist(ctx context.Context, cmd *models.UpdatePlaylistCommand) error @@ -99,7 +93,6 @@ type Store interface { GetAlertById(ctx context.Context, query *models.GetAlertByIdQuery) error GetAllAlertQueryHandler(ctx context.Context, query *models.GetAllAlertsQuery) error HandleAlertsQuery(ctx context.Context, query *models.GetAlertsQuery) error - SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error PauseAlert(ctx context.Context, cmd *models.PauseAlertCommand) error PauseAllAlerts(ctx context.Context, cmd *models.PauseAllAlertCommand) error @@ -109,15 +102,12 @@ type Store interface { GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQuery) error SearchOrgUsers(ctx context.Context, query *models.SearchOrgUsersQuery) error RemoveOrgUser(ctx context.Context, cmd *models.RemoveOrgUserCommand) error - SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error - GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error - ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error GetDataSources(ctx context.Context, query *models.GetDataSourcesQuery) error GetDataSourcesByType(ctx context.Context, query *models.GetDataSourcesByTypeQuery) error @@ -157,4 +147,8 @@ type Store interface { ExpireOldUserInvites(ctx context.Context, cmd *models.ExpireTempUsersCommand) error GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error + HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error + GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error + GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error + GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error } diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index ecc260ae82a..97d41c79392 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -274,7 +274,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { require.NoError(t, err) err = sqlStore.AddTeamMember(userIds[2], testOrgID, groupId, false, 0) require.NoError(t, err) - err = testHelperUpdateDashboardAcl(t, sqlStore, 1, models.DashboardAcl{ + err = updateDashboardAcl(t, sqlStore, 1, &models.DashboardAcl{ DashboardID: 1, OrgID: testOrgID, Permission: models.PERMISSION_EDIT, TeamID: groupId, }) require.NoError(t, err) diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index b38c2f9e4ea..47da1eafb4a 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -113,17 +113,18 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args userCr // create user user = models.User{ - Email: args.Email, - Name: args.Name, - Login: args.Login, - Company: args.Company, - IsAdmin: args.IsAdmin, - IsDisabled: args.IsDisabled, - OrgId: orgID, - EmailVerified: args.EmailVerified, - Created: time.Now(), - Updated: time.Now(), - LastSeenAt: time.Now().AddDate(-10, 0, 0), + Email: args.Email, + Name: args.Name, + Login: args.Login, + Company: args.Company, + IsAdmin: args.IsAdmin, + IsDisabled: args.IsDisabled, + OrgId: orgID, + EmailVerified: args.EmailVerified, + Created: time.Now(), + Updated: time.Now(), + LastSeenAt: time.Now().AddDate(-10, 0, 0), + IsServiceAccount: false, } salt, err := util.GetRandomString(10) @@ -321,7 +322,7 @@ func (ss *SQLStore) CreateUser(ctx context.Context, cmd models.CreateUserCommand } func (ss SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error { - return withDbSession(ctx, x, func(sess *DBSession) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { user := new(models.User) has, err := sess.ID(query.Id).Get(user) @@ -630,7 +631,8 @@ func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { // 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") + whereConditions = append(whereConditions, "u.is_service_account = ?") + whereParams = append(whereParams, dialect.BooleanStr(false)) // Join with only most recent auth module joinCondition := `( diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index 84f787afbbc..1a50fe1ccf6 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -239,7 +239,7 @@ func TestUserDataAccess(t *testing.T) { }) require.Nil(t, err) - err = testHelperUpdateDashboardAcl(t, ss, 1, models.DashboardAcl{ + err = updateDashboardAcl(t, ss, 1, &models.DashboardAcl{ DashboardID: 1, OrgID: users[0].OrgId, UserID: users[1].Id, Permission: models.PERMISSION_EDIT, }) @@ -290,7 +290,7 @@ func TestUserDataAccess(t *testing.T) { }) require.Nil(t, err) - err = testHelperUpdateDashboardAcl(t, ss, 1, models.DashboardAcl{ + err = updateDashboardAcl(t, ss, 1, &models.DashboardAcl{ DashboardID: 1, OrgID: users[0].OrgId, UserID: users[1].Id, Permission: models.PERMISSION_EDIT, }) diff --git a/pkg/services/thumbs/crawler.go b/pkg/services/thumbs/crawler.go index d9ef0118eba..a17b48c4ae0 100644 --- a/pkg/services/thumbs/crawler.go +++ b/pkg/services/thumbs/crawler.go @@ -3,6 +3,7 @@ package thumbs import ( "context" "encoding/json" + "fmt" "os" "strings" "sync" @@ -83,6 +84,16 @@ func (r *simpleCrawler) broadcastStatus() { } func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error { + res, err := r.renderService.HasCapability(rendering.ScalingDownImages) + if err != nil { + return err + } + + if !res.IsSupported { + return fmt.Errorf("cant run dashboard crawler - rendering service needs to be updated. "+ + "current version: %s, requiredVersion: %s", r.renderService.Version(), res.SemverConstraint) + } + r.queueMutex.Lock() if r.IsRunning() { r.queueMutex.Unlock() diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go index 45fd30f3b2a..6c984d412ad 100644 --- a/pkg/services/thumbs/dummy.go +++ b/pkg/services/thumbs/dummy.go @@ -26,6 +26,16 @@ func (ds *dummyService) Enabled() bool { return false } +func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig { + return dashboardPreviewsSetupConfig{ + SystemRequirements: dashboardPreviewsSystemRequirements{ + Met: false, + RequiredImageRendererPluginVersion: "", + }, + ThumbnailsExist: false, + } +} + func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response { result := make(map[string]string) result["error"] = "Not enabled" diff --git a/pkg/services/thumbs/models.go b/pkg/services/thumbs/models.go index 38b2448b302..a7c128d195c 100644 --- a/pkg/services/thumbs/models.go +++ b/pkg/services/thumbs/models.go @@ -53,6 +53,16 @@ type crawlStatus struct { Last time.Time `json:"last,omitempty"` } +type dashboardPreviewsSystemRequirements struct { + Met bool `json:"met"` + RequiredImageRendererPluginVersion string `json:"requiredImageRendererPluginVersion"` +} + +type dashboardPreviewsSetupConfig struct { + SystemRequirements dashboardPreviewsSystemRequirements `json:"systemRequirements"` + ThumbnailsExist bool `json:"thumbnailsExist"` +} + type dashRenderer interface { // Run Assumes you have already authenticated as admin. @@ -69,6 +79,7 @@ type dashRenderer interface { type thumbnailRepo interface { updateThumbnailState(ctx context.Context, state models.ThumbnailState, meta models.DashboardThumbnailMeta) error + doThumbnailsExist(ctx context.Context) (bool, error) saveFromFile(ctx context.Context, filePath string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error) saveFromBytes(ctx context.Context, bytes []byte, mimeType string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error) getThumbnail(ctx context.Context, meta models.DashboardThumbnailMeta) (*models.DashboardThumbnail, error) diff --git a/pkg/services/thumbs/repo.go b/pkg/services/thumbs/repo.go index 7ce5d0aa006..71f7a7459a9 100644 --- a/pkg/services/thumbs/repo.go +++ b/pkg/services/thumbs/repo.go @@ -63,7 +63,7 @@ func (r *sqlThumbnailRepository) saveFromBytes(ctx context.Context, content []by _, err := r.store.SaveThumbnail(ctx, cmd) if err != nil { - r.log.Error("error saving to the db", "dashboardUID", meta.DashboardUID, "err", err) + r.log.Error("Error saving to the db", "dashboardUID", meta.DashboardUID, "err", err) return 0, err } @@ -91,3 +91,13 @@ func (r *sqlThumbnailRepository) findDashboardsWithStaleThumbnails(ctx context.C Kind: kind, }) } + +func (r *sqlThumbnailRepository) doThumbnailsExist(ctx context.Context) (bool, error) { + cmd := &models.FindDashboardThumbnailCountCommand{} + count, err := r.store.FindThumbnailCount(ctx, cmd) + if err != nil { + r.log.Error("Error finding thumbnails", "err", err) + return false, err + } + return count > 0, err +} diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go index 2de7249389c..f5939a3f7fc 100644 --- a/pkg/services/thumbs/service.go +++ b/pkg/services/thumbs/service.go @@ -27,6 +27,7 @@ type Service interface { Run(ctx context.Context) error Enabled() bool GetImage(c *models.ReqContext) + GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig // from dashboard page SetImage(c *models.ReqContext) // form post @@ -41,6 +42,7 @@ type Service interface { type thumbService struct { scheduleOptions crawlerScheduleOptions renderer dashRenderer + renderingService rendering.Service thumbnailRepo thumbnailRepo lockService *serverlock.ServerLockService features featuremgmt.FeatureToggles @@ -71,6 +73,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS OrgRole: models.ROLE_ADMIN, } return &thumbService{ + renderingService: renderService, renderer: newSimpleCrawler(renderService, gl, thumbnailRepo), thumbnailRepo: thumbnailRepo, features: features, @@ -196,6 +199,44 @@ func (hs *thumbService) GetImage(c *models.ReqContext) { } } +func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig { + systemRequirements := hs.getSystemRequirements() + thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(c.Req.Context()) + + if err != nil { + return dashboardPreviewsSetupConfig{ + SystemRequirements: systemRequirements, + ThumbnailsExist: false, + } + } + + return dashboardPreviewsSetupConfig{ + SystemRequirements: systemRequirements, + ThumbnailsExist: thumbnailsExist, + } +} + +func (hs *thumbService) getSystemRequirements() dashboardPreviewsSystemRequirements { + res, err := hs.renderingService.HasCapability(rendering.ScalingDownImages) + if err != nil { + hs.log.Error("Error when verifying dashboard previews system requirements thumbnail", "err", err.Error()) + return dashboardPreviewsSystemRequirements{ + Met: false, + } + } + + if !res.IsSupported { + return dashboardPreviewsSystemRequirements{ + Met: false, + RequiredImageRendererPluginVersion: res.SemverConstraint, + } + } + + return dashboardPreviewsSystemRequirements{ + Met: true, + } +} + // Hack for now -- lets you upload images explicitly func (hs *thumbService) SetImage(c *models.ReqContext) { req := hs.parseImageReq(c, false) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index b4999997dd3..51639e6748c 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -254,7 +254,8 @@ type Cfg struct { // CSPEnabled toggles Content Security Policy support. CSPEnabled bool // CSPTemplate contains the Content Security Policy template. - CSPTemplate string + CSPTemplate string + AngularSupportEnabled bool TempDataLifetime time.Duration PluginsEnableAlpha bool @@ -1191,6 +1192,7 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { cfg.StrictTransportSecuritySubDomains = security.Key("strict_transport_security_subdomains").MustBool(false) cfg.CSPEnabled = security.Key("content_security_policy").MustBool(false) cfg.CSPTemplate = security.Key("content_security_policy_template").MustString("") + cfg.AngularSupportEnabled = security.Key("angular_support_enabled").MustBool(true) // read data source proxy whitelist DataProxyWhiteList = make(map[string]bool) diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index ea385e2b50e..2873ea422d4 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -52,8 +52,8 @@ const ( // changing this value is discouraged because this could cause existing alert definition // with intervals that are not exactly divided by this number not to be evaluated SchedulerBaseInterval = 10 * time.Second - // DefaultAlertForDuration indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting - DefaultAlertForDuration = 60 * time.Second + // DefaultRuleEvaluationInterval indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting + DefaultRuleEvaluationInterval = SchedulerBaseInterval * 6 // == 60 seconds ) type UnifiedAlertingSettings struct { @@ -75,8 +75,8 @@ type UnifiedAlertingSettings struct { // BaseInterval interval of time the scheduler updates the rules and evaluates rules. // Only for internal use and not user configuration. BaseInterval time.Duration - // DefaultAlertForDuration default time for how long an alert rule should be evaluated before change state. - DefaultAlertForDuration time.Duration + // DefaultRuleEvaluationInterval default interval between evaluations of a rule. + DefaultRuleEvaluationInterval time.Duration } // IsEnabled returns true if UnifiedAlertingSettings.Enabled is either nil or true. @@ -239,9 +239,9 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { } uaCfg.MinInterval = uaMinInterval - uaCfg.DefaultAlertForDuration = DefaultAlertForDuration - if uaMinInterval > uaCfg.DefaultAlertForDuration { - uaCfg.DefaultAlertForDuration = uaMinInterval + uaCfg.DefaultRuleEvaluationInterval = DefaultRuleEvaluationInterval + if uaMinInterval > uaCfg.DefaultRuleEvaluationInterval { + uaCfg.DefaultRuleEvaluationInterval = uaMinInterval } cfg.UnifiedAlerting = uaCfg diff --git a/pkg/setting/setting_unified_alerting_test.go b/pkg/setting/setting_unified_alerting_test.go index 69d92056112..5968688483c 100644 --- a/pkg/setting/setting_unified_alerting_test.go +++ b/pkg/setting/setting_unified_alerting_test.go @@ -70,7 +70,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 90*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, DefaultAlertForDuration, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { @@ -95,7 +95,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, true, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 160*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { @@ -119,7 +119,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, schedulereDefaultExecuteAlerts, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, evaluatorDefaultEvaluationTimeout, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, DefaultAlertForDuration, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { @@ -143,7 +143,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 160*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, } @@ -227,10 +227,12 @@ func TestMinInterval(t *testing.T) { }, }, { - desc: "should adjust DefaultAlertForDuration to min interval if it is greater", - minInterval: randPredicate(func(dur time.Duration) bool { return dur%SchedulerBaseInterval == 0 && dur > DefaultAlertForDuration }), + desc: "should adjust DefaultRuleEvaluationInterval to min interval if it is greater", + minInterval: randPredicate(func(dur time.Duration) bool { + return dur%SchedulerBaseInterval == 0 && dur > DefaultRuleEvaluationInterval + }), verifyCfg: func(t *testing.T, cfg *Cfg, err error) { - require.Equal(t, cfg.UnifiedAlerting.MinInterval, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, cfg.UnifiedAlerting.MinInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index bd2fc42e7a0..5a0d21fe0a6 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -12,15 +12,15 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards/database" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -2631,7 +2631,8 @@ func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folder "title": folderName, }), } - f, err := store.SaveDashboard(cmd) + dashboardsStore := database.ProvideDashboardStore(store) + f, err := dashboardsStore.SaveDashboard(cmd) if err != nil { return "", err diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index ccaa162b0af..011443c546b 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -634,6 +635,7 @@ func TestPrometheusRulesPermissions(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + dashboardsStore := dashboardsstore.ProvideDashboardStore(store) // override bus to get the GetSignedInUserQuery handler store.Bus = bus.GetBus() @@ -729,7 +731,7 @@ func TestPrometheusRulesPermissions(t *testing.T) { } // remove permissions from folder2 - require.NoError(t, store.UpdateDashboardACL(context.Background(), 2, nil)) + require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 2, nil)) // make sure that folder2 is not included in the response { @@ -778,7 +780,7 @@ func TestPrometheusRulesPermissions(t *testing.T) { } // remove permissions from _ALL_ folders - require.NoError(t, store.UpdateDashboardACL(context.Background(), 1, nil)) + require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 1, nil)) // make sure that no folders are included in the response { diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index a90dcdaabca..c3576ac77c6 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -34,6 +35,7 @@ func TestAlertRulePermissions(t *testing.T) { }) grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + dashboardsStore := dashboardsstore.ProvideDashboardStore(store) // override bus to get the GetSignedInUserQuery handler store.Bus = bus.GetBus() @@ -180,7 +182,7 @@ func TestAlertRulePermissions(t *testing.T) { assert.JSONEq(t, expectedGetNamespaceResponseBody, body) // remove permissions from folder2 - require.NoError(t, store.UpdateDashboardACL(context.Background(), 2, nil)) + require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 2, nil)) // make sure that folder2 is not included in the response // nolint:gosec @@ -253,7 +255,7 @@ func TestAlertRulePermissions(t *testing.T) { } // Remove permissions from ALL folders. - require.NoError(t, store.UpdateDashboardACL(context.Background(), 1, nil)) + require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 1, nil)) { u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) // nolint:gosec diff --git a/pkg/tests/api/metrics/api_metrics_test.go b/pkg/tests/api/metrics/api_metrics_test.go index 02d6cea7383..c23ad995701 100644 --- a/pkg/tests/api/metrics/api_metrics_test.go +++ b/pkg/tests/api/metrics/api_metrics_test.go @@ -32,7 +32,7 @@ func TestQueryCloudWatchMetrics(t *testing.T) { grafDir, cfgPath := testinfra.CreateGrafDir(t) addr, sqlStore := testinfra.StartGrafana(t, grafDir, cfgPath) - setUpDatabase(t, sqlStore) + setUpDatabase(t, sqlStore, "metrics") origNewCWClient := cloudwatch.NewCWClient t.Cleanup(func() { @@ -56,47 +56,27 @@ func TestQueryCloudWatchMetrics(t *testing.T) { }, }, } + result := getCWMetrics(t, 1, addr) - req := dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.NewFromAny(map[string]interface{}{ - "type": "metricFindQuery", - "subtype": "metrics", - "region": "us-east-1", - "namespace": "custom", - "datasourceId": 1, - }), - }, + type suggestData struct { + Text string + Value string + Label string } - result := makeCWRequest(t, req, addr) - - dataFrames := data.Frames{ - &data.Frame{ - RefID: "A", - Fields: []*data.Field{ - data.NewField("text", nil, []string{"Test_MetricName"}), - data.NewField("value", nil, []string{"Test_MetricName"}), - }, - Meta: &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": float64(1), - }, - }, - }, + expect := []suggestData{ + {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"}, } - - expect := backend.NewQueryDataResponse() - expect.Responses["A"] = backend.DataResponse{ - Frames: dataFrames, - } - assert.Equal(t, *expect, result) + actual := []suggestData{} + err := json.Unmarshal(result, &actual) + require.NoError(t, err) + assert.Equal(t, expect, actual) }) } func TestQueryCloudWatchLogs(t *testing.T) { grafDir, cfgPath := testinfra.CreateGrafDir(t) addr, store := testinfra.StartGrafana(t, grafDir, cfgPath) - setUpDatabase(t, store) + setUpDatabase(t, store, "logs") origNewCWLogsClient := cloudwatch.NewCWLogsClient t.Cleanup(func() { @@ -141,6 +121,28 @@ func TestQueryCloudWatchLogs(t *testing.T) { }) } +func getCWMetrics(t *testing.T, datasourceId int, addr string) []byte { + t.Helper() + + u := fmt.Sprintf("http://%s/api/datasources/%v/resources/metrics?region=us-east-1&namespace=custom", addr, datasourceId) + t.Logf("Making GET request to %s", u) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + require.NotNil(t, resp) + t.Cleanup(func() { + err := resp.Body.Close() + assert.NoError(t, err) + }) + + buf := bytes.Buffer{} + _, err = io.Copy(&buf, resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + return buf.Bytes() +} + func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.QueryDataResponse { t.Helper() @@ -171,12 +173,13 @@ func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.Qu return tr } -func setUpDatabase(t *testing.T, store *sqlstore.SQLStore) { +func setUpDatabase(t *testing.T, store *sqlstore.SQLStore, uid string) { t.Helper() err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { _, err := sess.Insert(&models.DataSource{ - Id: 1, + Id: 1, + Uid: uid, // This will be the ID of the main org OrgId: 2, Name: "Test", diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go index 6d12302416a..c501fa9e057 100644 --- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go @@ -235,7 +235,7 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return AzureResourceGraphResponse{}, fmt.Errorf("request failed, status: %s, body: %s", res.Status, string(body)) + return AzureResourceGraphResponse{}, fmt.Errorf("%s. Azure Resource Graph error: %s", res.Status, string(body)) } var data AzureResourceGraphResponse diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go index cbba2109faa..42466777a9d 100644 --- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go +++ b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go @@ -2,7 +2,9 @@ package azuremonitor import ( "context" + "io" "net/http" + "strings" "testing" "time" @@ -150,3 +152,46 @@ func TestGetAzurePortalUrl(t *testing.T) { assert.Equal(t, expectedAzurePortalUrl[cloud], azurePortalUrl) } } + +func TestUnmarshalResponse400(t *testing.T) { + datasource := &AzureResourceGraphDatasource{} + res, err := datasource.unmarshalResponse(&http.Response{ + StatusCode: 400, + Status: "400 Bad Request", + Body: io.NopCloser(strings.NewReader(("Azure Error Message"))), + }) + + expectedErrMsg := "400 Bad Request. Azure Resource Graph error: Azure Error Message" + + assert.Equal(t, expectedErrMsg, err.Error()) + assert.Empty(t, res) +} + +func TestUnmarshalResponse200Invalid(t *testing.T) { + datasource := &AzureResourceGraphDatasource{} + res, err := datasource.unmarshalResponse(&http.Response{ + StatusCode: 200, + Status: "OK", + Body: io.NopCloser(strings.NewReader(("Azure Data"))), + }) + + expectedRes := AzureResourceGraphResponse{} + expectedErr := "invalid character 'A' looking for beginning of value" + + assert.Equal(t, expectedErr, err.Error()) + assert.Equal(t, expectedRes, res) +} + +func TestUnmarshalResponse200(t *testing.T) { + datasource := &AzureResourceGraphDatasource{} + res, err2 := datasource.unmarshalResponse(&http.Response{ + StatusCode: 200, + Status: "OK", + Body: io.NopCloser(strings.NewReader("{}")), + }) + + expectedRes := AzureResourceGraphResponse{} + + assert.NoError(t, err2) + assert.Equal(t, expectedRes, res) +} diff --git a/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json b/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json new file mode 100644 index 00000000000..453d3913f01 --- /dev/null +++ b/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json @@ -0,0 +1,17 @@ +{ + "timeSeries": [ + { + "metric": { + "type": "loadbalancing.googleapis.com\/https\/backend_latencies" + }, + "resource": { + "type": "https_lb_rule", + "labels": { + "project_id": "grafana-prod" + } + }, + "metricKind": "DELTA", + "valueType": "DISTRIBUTION" + } + ] +} diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 818565f858b..2f924899e08 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -193,6 +193,9 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes valueField, }, RefID: timeSeriesFilter.RefID, + Meta: &data.FrameMeta{ + ExecutedQueryString: executedQueryString, + }, } if maxKey < i { @@ -220,13 +223,20 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes timeField, valueField, }, + Meta: &data.FrameMeta{ + ExecutedQueryString: executedQueryString, + }, } } } } for i := 0; i < len(buckets); i++ { + buckets[i].Meta.Custom = customFrameMeta frames = append(frames, buckets[i]) } + if len(buckets) == 0 { + frames = append(frames, frame) + } } if len(response.TimeSeries) > 0 { dl := timeSeriesFilter.buildDeepLink() diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go index 909652a3731..72812087e51 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go @@ -11,6 +11,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkdata "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -386,6 +388,77 @@ func TestTimeSeriesFilter(t *testing.T) { require.True(t, ok) assert.Equal(t, "114250375703598695", labels["resource.label.instance_id"]) }) + + t.Run("parseResponse successfully parses metadata for distribution valueType", func(t *testing.T) { + t.Run("exponential bounds", func(t *testing.T) { + data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + + require.NotNil(t, res.Frames[0].Meta) + assert.Equal(t, sdkdata.FrameMeta{ + ExecutedQueryString: "test_query", + Custom: map[string]interface{}{ + "groupBys": []string{"test_group_by"}, + "alignmentPeriod": "", + "labels": map[string]string{ + "resource.label.project_id": "grafana-prod", + "resource.type": "https_lb_rule", + }, + "perSeriesAligner": "", + }, + }, *res.Frames[0].Meta) + }) + + t.Run("explicit bounds", func(t *testing.T) { + data, err := loadTestFile("./test-data/4-series-response-distribution-explicit.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + + require.NotNil(t, res.Frames[0].Meta) + assert.Equal(t, sdkdata.FrameMeta{ + ExecutedQueryString: "test_query", + Custom: map[string]interface{}{ + "groupBys": []string{"test_group_by"}, + "alignmentPeriod": "", + "labels": map[string]string{ + "resource.label.project_id": "grafana-demo", + "resource.type": "global", + }, + "perSeriesAligner": "", + }, + }, *res.Frames[0].Meta) + }) + + t.Run("without series points", func(t *testing.T) { + data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + + require.NotNil(t, res.Frames[0].Meta) + assert.Equal(t, sdkdata.FrameMeta{ + ExecutedQueryString: "test_query", + Custom: map[string]interface{}{ + "groupBys": []string{"test_group_by"}, + "alignmentPeriod": "", + "labels": map[string]string{ + "resource.label.project_id": "grafana-prod", + "resource.type": "https_lb_rule", + }, + "perSeriesAligner": "", + }, + }, *res.Frames[0].Meta) + }) + }) } func loadTestFile(path string) (cloudMonitoringResponse, error) { diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 64799982122..a2d65a23bde 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "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/backend/resource/httpadapter" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" @@ -83,11 +84,13 @@ type SessionCache interface { } func newExecutor(im instancemgmt.InstanceManager, cfg *setting.Cfg, sessions SessionCache) *cloudWatchExecutor { - return &cloudWatchExecutor{ + cwe := &cloudWatchExecutor{ im: im, cfg: cfg, sessions: sessions, } + cwe.resourceHandler = httpadapter.New(cwe.newResourceMux()) + return cwe } func NewInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc { @@ -158,6 +161,12 @@ type cloudWatchExecutor struct { im instancemgmt.InstanceManager cfg *setting.Cfg sessions SessionCache + + resourceHandler backend.CallResourceHandler +} + +func (e *cloudWatchExecutor) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return e.resourceHandler.CallResource(ctx, req, sender) } func (e *cloudWatchExecutor) newSession(pluginCtx backend.PluginContext, region string) (*session.Session, error) { @@ -283,8 +292,6 @@ func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDa var result *backend.QueryDataResponse switch queryType { - case "metricFindQuery": - result, err = e.executeMetricFindQuery(req.PluginContext, model, q) case "annotationQuery": result, err = e.executeAnnotationQuery(req.PluginContext, model, q) case "logAction": diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index d4ec36f073d..0284f73d996 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -1,8 +1,10 @@ package cloudwatch import ( + "encoding/json" "errors" "fmt" + "net/url" "reflect" "sort" "strings" @@ -15,15 +17,14 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/util/errutil" ) type suggestData struct { - Text string - Value string + Text string `json:"text"` + Value string `json:"value"` + Label string `json:"label,omitempty"` } type customMetricsCache struct { @@ -36,61 +37,6 @@ var customMetricsDimensionsMap = make(map[string]map[string]map[string]*customMe var regionCache sync.Map -func (e *cloudWatchExecutor) executeMetricFindQuery(pluginCtx backend.PluginContext, model *simplejson.Json, query backend.DataQuery) (*backend.QueryDataResponse, error) { - subType := model.Get("subtype").MustString() - - var data []suggestData - var err error - switch subType { - case "regions": - data, err = e.handleGetRegions(pluginCtx) - case "namespaces": - data, err = e.handleGetNamespaces(pluginCtx) - case "metrics": - data, err = e.handleGetMetrics(pluginCtx, model) - case "all_metrics": - data, err = e.handleGetAllMetrics() - case "dimension_keys": - data, err = e.handleGetDimensions(pluginCtx, model) - case "dimension_values": - data, err = e.handleGetDimensionValues(pluginCtx, model) - case "ebs_volume_ids": - data, err = e.handleGetEbsVolumeIds(pluginCtx, model) - case "ec2_instance_attribute": - data, err = e.handleGetEc2InstanceAttribute(pluginCtx, model) - case "resource_arns": - data, err = e.handleGetResourceArns(pluginCtx, model) - } - if err != nil { - return nil, err - } - - resp := backend.NewQueryDataResponse() - respD := resp.Responses[query.RefID] - respD.Frames = append(respD.Frames, transformToTable(data)) - resp.Responses[query.RefID] = respD - - return resp, nil -} - -func transformToTable(d []suggestData) *data.Frame { - frame := data.NewFrame("", - data.NewField("text", nil, []string{}), - data.NewField("value", nil, []string{})) - - for _, r := range d { - frame.AppendRow(r.Text, r.Value) - } - - frame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": len(d), - }, - } - - return frame -} - func parseMultiSelectValue(input string) []string { trimmedInput := strings.TrimSpace(input) if strings.HasPrefix(trimmedInput, "{") { @@ -107,7 +53,7 @@ func parseMultiSelectValue(input string) []string { // Whenever this list is updated, the frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html -func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext) ([]suggestData, error) { +func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { dsInfo, err := e.getDSInfo(pluginCtx) if err != nil { return nil, err @@ -149,14 +95,14 @@ func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext) ( result := make([]suggestData, 0) for _, region := range regions { - result = append(result, suggestData{Text: region, Value: region}) + result = append(result, suggestData{Text: region, Value: region, Label: region}) } regionCache.Store(profile, result) return result, nil } -func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext) ([]suggestData, error) { +func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { var keys []string for key := range metricsMap { keys = append(keys, key) @@ -175,15 +121,15 @@ func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext result := make([]suggestData, 0) for _, key := range keys { - result = append(result, suggestData{Text: key, Value: key}) + result = append(result, suggestData{Text: key, Value: key, Label: key}) } return result, nil } -func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) { - region := parameters.Get("region").MustString() - namespace := parameters.Get("namespace").MustString() +func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { + region := parameters.Get("region") + namespace := parameters.Get("namespace") var namespaceMetrics []string if !isCustomMetrics(namespace) { @@ -201,32 +147,40 @@ func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, p result := make([]suggestData, 0) for _, name := range namespaceMetrics { - result = append(result, suggestData{Text: name, Value: name}) + result = append(result, suggestData{Text: name, Value: name, Label: name}) } return result, nil } // handleGetAllMetrics returns a slice of suggestData structs with metric and its namespace -func (e *cloudWatchExecutor) handleGetAllMetrics() ([]suggestData, error) { +func (e *cloudWatchExecutor) handleGetAllMetrics(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { result := make([]suggestData, 0) for namespace, metrics := range metricsMap { for _, metric := range metrics { - result = append(result, suggestData{Text: namespace, Value: metric}) + result = append(result, suggestData{Text: namespace, Value: metric, Label: namespace}) } } return result, nil } -// handleGetDimensions returns a slice of suggestData structs with dimension keys. +// handleGetDimensionKeys returns a slice of suggestData structs with dimension keys. // If a dimension filters parameter is specified, a new api call to list metrics will be issued to load dimension keys for the given filter. // If no dimension filter is specified, dimension keys will be retrieved from the hard coded map in this file. -func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) { - region := parameters.Get("region").MustString() - namespace := parameters.Get("namespace").MustString() - metricName := parameters.Get("metricName").MustString("") - dimensionFilters := parameters.Get("dimensionFilters").MustMap() +func (e *cloudWatchExecutor) handleGetDimensionKeys(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { + region := parameters.Get("region") + namespace := parameters.Get("namespace") + metricName := parameters.Get("metricName") + dimensionFilterJson := parameters.Get("dimensionFilters") + + dimensionFilters := map[string]interface{}{} + if dimensionFilterJson != "" { + err := json.Unmarshal([]byte(dimensionFilterJson), &dimensionFilters) + if err != nil { + return nil, fmt.Errorf("error unmarshaling dimensionFilters: %v", err) + } + } var dimensionValues []string if !isCustomMetrics(namespace) { @@ -303,7 +257,7 @@ func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext result := make([]suggestData, 0) for _, name := range dimensionValues { - result = append(result, suggestData{Text: name, Value: name}) + result = append(result, suggestData{Text: name, Value: name, Label: name}) } return result, nil @@ -311,12 +265,18 @@ func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext // handleGetDimensionValues returns a slice of suggestData structs with dimension values. // A call to the list metrics api is issued to retrieve the dimension values. All parameters are used as input args to the list metrics call. -func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) { - region := parameters.Get("region").MustString() - namespace := parameters.Get("namespace").MustString() - metricName := parameters.Get("metricName").MustString() - dimensionKey := parameters.Get("dimensionKey").MustString() - dimensionsJson := parameters.Get("dimensions").MustMap() +func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { + region := parameters.Get("region") + namespace := parameters.Get("namespace") + metricName := parameters.Get("metricName") + dimensionKey := parameters.Get("dimensionKey") + dimensionsJson := parameters.Get("dimensions") + + dimensionsValues := map[string]interface{}{} + err := json.Unmarshal([]byte(dimensionsJson), &dimensionsValues) + if err != nil { + return nil, fmt.Errorf("error unmarshaling dimension: %v", err) + } var dimensions []*cloudwatch.DimensionFilter addDimension := func(key string, value string) { @@ -329,8 +289,8 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo } dimensions = append(dimensions, filter) } - for k, v := range dimensionsJson { - // due to legacy, value can be a string, a string slice or nil + + for k, v := range dimensionsValues { if vv, ok := v.(string); ok { addDimension(k, vv) } else if vv, ok := v.([]interface{}); ok { @@ -364,7 +324,7 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo } dupCheck[*dim.Value] = true - result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value}) + result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value, Label: *dim.Value}) } } } @@ -376,9 +336,9 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo return result, nil } -func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) { - region := parameters.Get("region").MustString() - instanceId := parameters.Get("instanceId").MustString() +func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { + region := parameters.Get("region") + instanceId := parameters.Get("instanceId") instanceIds := aws.StringSlice(parseMultiSelectValue(instanceId)) instances, err := e.ec2DescribeInstances(pluginCtx, region, nil, instanceIds) @@ -390,7 +350,7 @@ func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginConte for _, reservation := range instances.Reservations { for _, instance := range reservation.Instances { for _, mapping := range instance.BlockDeviceMappings { - result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId}) + result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId, Label: *mapping.Ebs.VolumeId}) } } } @@ -398,13 +358,19 @@ func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginConte return result, nil } -func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) { - region := parameters.Get("region").MustString() - attributeName := parameters.Get("attributeName").MustString() - filterJson := parameters.Get("filters").MustMap() +func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { + region := parameters.Get("region") + attributeName := parameters.Get("attributeName") + filterJson := parameters.Get("filters") + + filterMap := map[string]interface{}{} + err := json.Unmarshal([]byte(filterJson), &filterMap) + if err != nil { + return nil, fmt.Errorf("error unmarshaling filter: %v", err) + } var filters []*ec2.Filter - for k, v := range filterJson { + for k, v := range filterMap { if vv, ok := v.([]interface{}); ok { var values []*string for _, vvv := range vv { @@ -466,7 +432,7 @@ func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.Plu } dupCheck[data] = true - result = append(result, suggestData{Text: data, Value: data}) + result = append(result, suggestData{Text: data, Value: data, Label: data}) } } @@ -477,13 +443,19 @@ func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.Plu return result, nil } -func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) { - region := parameters.Get("region").MustString() - resourceType := parameters.Get("resourceType").MustString() - filterJson := parameters.Get("tags").MustMap() +func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { + region := parameters.Get("region") + resourceType := parameters.Get("resourceType") + tagsJson := parameters.Get("tags") + + tagsMap := map[string]interface{}{} + err := json.Unmarshal([]byte(tagsJson), &tagsMap) + if err != nil { + return nil, fmt.Errorf("error unmarshaling filter: %v", err) + } var filters []*resourcegroupstaggingapi.TagFilter - for k, v := range filterJson { + for k, v := range tagsMap { if vv, ok := v.([]interface{}); ok { var values []*string for _, vvv := range vv { @@ -509,7 +481,7 @@ func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginConte result := make([]suggestData, 0) for _, resource := range resources.ResourceTagMappingList { data := *resource.ResourceARN - result = append(result, suggestData{Text: data, Value: data}) + result = append(result, suggestData{Text: data, Value: data, Label: data}) } return result, nil diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 705bb04c328..ebc0e25da01 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -1,8 +1,8 @@ package cloudwatch import ( - "context" "encoding/json" + "net/url" "testing" "github.com/aws/aws-sdk-go/aws" @@ -54,40 +54,20 @@ func TestQuery_Metrics(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetMetrics( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, url.Values{ + "region": []string{"us-east-1"}, + "namespace": []string{"custom"}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "metrics", - "region": "us-east-1", - "namespace": "custom" - }`), - }, - }, - }) + ) require.NoError(t, err) - expFrame := data.NewFrame( - "", - data.NewField("text", nil, []string{"Test_MetricName"}), - data.NewField("value", nil, []string{"Test_MetricName"}), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": 1, - }, + expResponse := []suggestData{ + {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"}, } - - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + assert.Equal(t, expResponse, resp) }) t.Run("Dimension keys for custom metrics", func(t *testing.T) { @@ -109,39 +89,20 @@ func TestQuery_Metrics(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetDimensionKeys( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, url.Values{ + "region": []string{"us-east-1"}, + "namespace": []string{"custom"}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "dimension_keys", - "region": "us-east-1", - "namespace": "custom" - }`), - }, - }, - }) + ) require.NoError(t, err) - expFrame := data.NewFrame( - "", - data.NewField("text", nil, []string{"Test_DimensionName"}), - data.NewField("value", nil, []string{"Test_DimensionName"}), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": 1, - }, + expResponse := []suggestData{ + {Text: "Test_DimensionName", Value: "Test_DimensionName", Label: "Test_DimensionName"}, } - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + assert.Equal(t, expResponse, resp) }) } @@ -168,21 +129,14 @@ func TestQuery_Regions(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetRegions( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, url.Values{ + "region": []string{"us-east-1"}, + "namespace": []string{"custom"}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "regions", - "region": "us-east-1", - "namespace": "custom" - }`), - }, - }, - }) + ) require.NoError(t, err) expRegions := append(knownRegions, regionName) @@ -197,12 +151,11 @@ func TestQuery_Regions(t *testing.T) { }, } - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + expResponse := []suggestData{} + for _, region := range expRegions { + expResponse = append(expResponse, suggestData{Text: region, Value: region, Label: region}) + } + assert.Equal(t, expResponse, resp) }) } @@ -242,44 +195,28 @@ func TestQuery_InstanceAttributes(t *testing.T) { return datasourceInfo{}, nil }) - executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ - DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, - }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "ec2_instance_attribute", - "region": "us-east-1", - "attributeName": "InstanceId", - "filters": { - "tag:Environment": ["production"] - } - }`), - }, - }, - }) + filterMap := map[string][]string{ + "tag:Environment": {"production"}, + } + filterJson, err := json.Marshal(filterMap) require.NoError(t, err) - expFrame := data.NewFrame( - "", - data.NewField("text", nil, []string{instanceID}), - data.NewField("value", nil, []string{instanceID}), + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) + resp, err := executor.handleGetEc2InstanceAttribute( + backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, url.Values{ + "region": []string{"us-east-1"}, + "attributeName": []string{"InstanceId"}, + "filters": []string{string(filterJson)}, + }, ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": 1, - }, - } + require.NoError(t, err) - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + expResponse := []suggestData{ + {Text: instanceID, Value: instanceID, Label: instanceID}, + } + assert.Equal(t, expResponse, resp) }) } @@ -342,41 +279,22 @@ func TestQuery_EBSVolumeIDs(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetEbsVolumeIds( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, url.Values{ + "region": []string{"us-east-1"}, + "instanceId": []string{"{i-1, i-2, i-3}"}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "ebs_volume_ids", - "region": "us-east-1", - "instanceId": "{i-1, i-2, i-3}" - }`), - }, - }, - }) + ) require.NoError(t, err) expValues := []string{"vol-1-1", "vol-1-2", "vol-2-1", "vol-2-2", "vol-3-1", "vol-3-2"} - expFrame := data.NewFrame( - "", - data.NewField("text", nil, expValues), - data.NewField("value", nil, expValues), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": 6, - }, + expResponse := []suggestData{} + for _, value := range expValues { + expResponse = append(expResponse, suggestData{Text: value, Value: value, Label: value}) } - - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + assert.Equal(t, expResponse, resp) }) } @@ -420,48 +338,33 @@ func TestQuery_ResourceARNs(t *testing.T) { return datasourceInfo{}, nil }) + tagMap := map[string][]string{ + "Environment": {"production"}, + } + tagJson, err := json.Marshal(tagMap) + require.NoError(t, err) + executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetResourceArns( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, + }, url.Values{ + "region": []string{"us-east-1"}, + "resourceType": []string{"ec2:instance"}, + "tags": []string{string(tagJson)}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "resource_arns", - "region": "us-east-1", - "resourceType": "ec2:instance", - "tags": { - "Environment": ["production"] - } - }`), - }, - }, - }) + ) require.NoError(t, err) expValues := []string{ "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567", "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321", } - expFrame := data.NewFrame( - "", - data.NewField("text", nil, expValues), - data.NewField("value", nil, expValues), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": 2, - }, + expResponse := []suggestData{} + for _, value := range expValues { + expResponse = append(expResponse, suggestData{Text: value, Value: value, Label: value}) } - - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + assert.Equal(t, expResponse, resp) }) } @@ -472,20 +375,14 @@ func TestQuery_GetAllMetrics(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetAllMetrics( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "all_metrics", - "region": "us-east-1" - }`), - }, + url.Values{ + "region": []string{"us-east-1"}, }, - }) + ) require.NoError(t, err) metricCount := 0 @@ -493,7 +390,7 @@ func TestQuery_GetAllMetrics(t *testing.T) { metricCount += len(metrics) } - assert.Equal(t, metricCount, resp.Responses[""].Frames[0].Fields[1].Len()) + assert.Equal(t, metricCount, len(resp)) }) } @@ -527,46 +424,28 @@ func TestQuery_GetDimensionKeys(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetDimensionKeys( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "dimension_keys", - "region": "us-east-1", - "namespace": "AWS/EC2", - "dimensionFilters": { - "InstanceId": "", - "AutoscalingGroup": [] - } - }`), - }, + url.Values{ + "region": []string{"us-east-1"}, + "namespace": []string{"AWS/EC2"}, + "dimensionFilters": []string{`{ + "InstanceId": "", + "AutoscalingGroup": [] + }`}, }, - }) - + ) require.NoError(t, err) expValues := []string{"Dimension1", "Dimension2", "Dimension3"} - expFrame := data.NewFrame( - "", - data.NewField("text", nil, expValues), - data.NewField("value", nil, expValues), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": len(expValues), - }, + expResponse := []suggestData{} + for _, val := range expValues { + expResponse = append(expResponse, suggestData{val, val, val}) } - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + assert.Equal(t, expResponse, resp) }) t.Run("should return hard coded metrics when no dimension filter is specified", func(t *testing.T) { @@ -575,42 +454,25 @@ func TestQuery_GetDimensionKeys(t *testing.T) { }) executor := newExecutor(im, newTestConfig(), fakeSessionCache{}) - resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ - PluginContext: backend.PluginContext{ + resp, err := executor.handleGetDimensionKeys( + backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }, - Queries: []backend.DataQuery{ - { - JSON: json.RawMessage(`{ - "type": "metricFindQuery", - "subtype": "dimension_keys", - "region": "us-east-1", - "namespace": "AWS/EC2", - "dimensionFilters": {} - }`), - }, + url.Values{ + "region": []string{"us-east-1"}, + "namespace": []string{"AWS/EC2"}, + "dimensionFilters": []string{`{}`}, }, - }) + ) require.NoError(t, err) expValues := dimensionsMap["AWS/EC2"] - expFrame := data.NewFrame( - "", - data.NewField("text", nil, expValues), - data.NewField("value", nil, expValues), - ) - expFrame.Meta = &data.FrameMeta{ - Custom: map[string]interface{}{ - "rowCount": len(expValues), - }, + expResponse := []suggestData{} + for _, val := range expValues { + expResponse = append(expResponse, suggestData{val, val, val}) } - assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{ - "": { - Frames: data.Frames{expFrame}, - }, - }, - }, resp) + assert.Equal(t, expResponse, resp) }) } func Test_isCustomMetrics(t *testing.T) { diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go index 4ea31bbbc3d..6234f013913 100644 --- a/pkg/tsdb/cloudwatch/metrics.go +++ b/pkg/tsdb/cloudwatch/metrics.go @@ -325,75 +325,77 @@ var metricsMap = map[string][]string{ "TouchMisses", "UnusedMemory", }, - "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"}, - "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", "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"}, - "AWS/GlobalAccelerator": {"NewFlowCount", "ProcessedBytesIn", "ProcessedBytesOut", "HealthyEndpointCount", "UnhealthyEndpointCount"}, - "AWS/Glue": {"glue.driver.BlockManager.disk.diskSpaceUsed_MB", "glue.driver.ExecutorAllocationManager.executors.numberAllExecutors", "glue.driver.ExecutorAllocationManager.executors.numberMaxNeededExecutors", "glue.driver.aggregate.bytesRead", "glue.driver.aggregate.elapsedTime", "glue.driver.aggregate.numCompletedStages", "glue.driver.aggregate.numCompletedTasks", "glue.driver.aggregate.numFailedTasks", "glue.driver.aggregate.numKilledTasks", "glue.driver.aggregate.recordsRead", "glue.driver.aggregate.shuffleBytesWritten", "glue.driver.aggregate.shuffleLocalBytesRead", "glue.driver.jvm.heap.usage glue.executorId.jvm.heap.usage glue.ALL.jvm.heap.usage", "glue.driver.jvm.heap.used glue.executorId.jvm.heap.used glue.ALL.jvm.heap.used", "glue.driver.s3.filesystem.read_bytes glue.executorId.s3.filesystem.read_bytes glue.ALL.s3.filesystem.read_bytes", "glue.driver.s3.filesystem.write_bytes glue.executorId.s3.filesystem.write_bytes glue.ALL.s3.filesystem.write_bytes", "glue.driver.system.cpuSystemLoad glue.executorId.system.cpuSystemLoad glue.ALL.system.cpuSystemLoad"}, - "AWS/GroundStation": {"BitErrorRate", "BlockErrorRate", "ReceivedPower", "Es/N0"}, - "AWS/Inspector": {"TotalAssessmentRunFindings", "TotalAssessmentRuns", "TotalHealthyAgents", "TotalMatchingAgents"}, - "AWS/IVS": {"ConcurrentViews", "ConcurrentStreams", "LiveDeliveredTime", "LiveInputTime", "RecordedTime"}, - "AWS/IoT": {"CanceledJobExecutionCount", "CanceledJobExecutionTotalCount", "ClientError", "Connect.AuthError", "Connect.ClientError", "Connect.ServerError", "Connect.Success", "Connect.Throttle", "DeleteThingShadow.Accepted", "FailedJobExecutionCount", "FailedJobExecutionTotalCount", "Failure", "GetThingShadow.Accepted", "InProgressJobExecutionCount", "InProgressJobExecutionTotalCount", "NonCompliantResources", "NumLogBatchesFailedToPublishThrottled", "NumLogEventsFailedToPublishThrottled", "ParseError", "Ping.Success", "PublishIn.AuthError", "PublishIn.ClientError", "PublishIn.ServerError", "PublishIn.Success", "PublishIn.Throttle", "PublishOut.AuthError", "PublishOut.ClientError", "PublishOut.Success", "QueuedJobExecutionCount", "QueuedJobExecutionTotalCount", "RejectedJobExecutionCount", "RejectedJobExecutionTotalCount", "RemovedJobExecutionCount", "RemovedJobExecutionTotalCount", "ResourcesEvaluated", "RuleMessageThrottled", "RuleNotFound", "RulesExecuted", "ServerError", "Subscribe.AuthError", "Subscribe.ClientError", "Subscribe.ServerError", "Subscribe.Success", "Subscribe.Throttle", "SuccededJobExecutionCount", "SuccededJobExecutionTotalCount", "Success", "TopicMatch", "Unsubscribe.ClientError", "Unsubscribe.ServerError", "Unsubscribe.Success", "Unsubscribe.Throttle", "UpdateThingShadow.Accepted", "Violations", "ViolationsCleared", "ViolationsInvalidated"}, - "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"}, - "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"}, - "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"}, - "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"}, - "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"}, - "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"}, - "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "Duration", "Errors", "Invocations", "IteratorAge", "Throttles", "UnreservedConcurrentExecutions"}, - "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"}, - "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"}, - "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"}, - "AWS/ML": {"PredictCount", "PredictFailureCount"}, - "AWS/MediaConnect": {"ARQRecovered", "ARQRequests", "BitRate", "CATError", "CRCError", "Connected", "ConnectedOutputs", "ContinuityCounter", "Disconnections", "DroppedPackets", "FECPackets", "FECRecovered", "NotRecoveredPackets", "OutputConnected", "OutputDisconnections", "OverflowPackets", "PATError", "PCRAccuracyError", "PCRError", "PIDError", "PMTError", "PTSError", "PacketLossPercent", "RecoveredPackets", "RoundTripTime", "SourceARQRecovered", "SourceARQRequests", "SourceBitRate", "SourceCATError", "SourceCRCError", "SourceConnected", "SourceContinuityCounter", "SourceDisconnections", "SourceDroppedPackets", "SourceFECPackets", "SourceFECRecovered", "SourceNotRecoveredPackets", "SourceOverflowPackets", "SourcePATError", "SourcePCRAccuracyError", "SourcePCRError", "SourcePIDError", "SourcePMTError", "SourcePTSError", "SourcePacketLossPercent", "SourceRecoveredPackets", "SourceRoundTripTime", "SourceTSByteError", "SourceTSSyncLoss", "SourceTotalPackets", "SourceTransportError", "TSByteError", "TSSyncLoss", "TotalPackets", "TransportError"}, - "AWS/MediaConvert": {"AudioOutputSeconds", "Errors", "HDOutputSeconds", "JobsCompletedCount", "JobsErroredCount", "SDOutputSeconds", "StandbyTime", "TranscodingTime", "UHDOutputSeconds"}, - "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"}, - "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"}, - "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"}, - "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"}, - "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"}, - "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, - "AWS/GatewayELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "UnHealthyHostCount"}, - "AWS/NetworkFirewall": {"DroppedPackets", "Packets", "PassedPackets", "ReceivedPacketCount"}, - "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_steal", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_15", "load_5", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, - "AWS/Polly": {"2XXCount", "4XXCount", "5XXCount", "RequestCharacters", "ResponseLatency"}, - "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraGlobalDBDataTransferBytes", "AuroraGlobalDBReplicatedWriteIO", "AuroraGlobalDBReplicationLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "AvailabilityPercentage", "BacktrackChangeRecordsCreationRate", "BacktrackChangeRecordsStored", "BacktrackWindowActual", "BacktrackWindowAlert", "BackupRetentionPeriodStorageUsed", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "ClientConnections", "ClientConnectionsClosed", "ClientConnectionsNoTLS", "ClientConnectionsReceived", "ClientConnectionsSetupFailedAuth", "ClientConnectionsSetupSucceeded", "ClientConnectionsTLS", "CommitLatency", "CommitThroughput", "DDLLatency", "DDLThroughput", "DMLLatency", "DMLThroughput", "DatabaseConnectionRequests", "DatabaseConnectionRequestsWithTLS", "DatabaseConnections", "DatabaseConnectionsBorrowLatency", "DatabaseConnectionsCurrentlyBorrowed", "DatabaseConnectionsCurrentlyInTransaction", "DatabaseConnectionsCurrentlySessionPinned", "DatabaseConnectionsSetupFailed", "DatabaseConnectionsSetupSucceeded", "DatabaseConnectionsWithTLS", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "EngineUptime", "FailedSQLServerAgentJobsCount", "FreeLocalStorage", "FreeStorageSpace", "FreeableMemory", "InsertLatency", "InsertThroughput", "LoginFailures", "MaxDatabaseConnectionsAllowed", "MaximumUsedTransactionIDs", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "OldestReplicationSlotLag", "Queries", "QueryDatabaseResponseLatency", "QueryRequests", "QueryRequestsNoTLS", "QueryRequestsTLS", "QueryResponseLatency", "RDSToAuroraPostgreSQLReplicaLag", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ReplicationSlotDiskUsage", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SnapshotStorageUsed", "SwapUsage", "TotalBackupStorageBilled", "TransactionLogsDiskUsage", "TransactionLogsGeneration", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs", "WriteIOPS", "WriteLatency", "WriteThroughput"}, - "AWS/Redshift": {"CommitQueueLength", "ConcurrencyScalingActiveClusters", "ConcurrencyScalingSeconds", "CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "MaxConfiguredConcurrencyScalingClusters", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "TotalTableCount", "WLMQueueLength", "WLMQueueWaitTime", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMRunningQueries", "WriteIOPS", "WriteLatency", "WriteThroughput", "SchemaQuota", "NumExceededSchemaQuotas", "StorageUsed", "PercentageQuotaUsed"}, - "AWS/Robomaker": {"RealTimeFactor", "vCPU", "Memory", "SimulationUnit"}, - "AWS/Route53": {"ChildHealthCheckHealthyCount", "ConnectionTime", "DNSQueries", "HealthCheckPercentageHealthy", "HealthCheckStatus", "SSLHandshakeTime", "TimeToFirstByte"}, - "AWS/Route53Resolver": {"InboundQueryVolume", "OutboundQueryVolume", "OutboundQueryAggregatedVolume"}, - "AWS/S3": {"4xxErrors", "5xxErrors", "AllRequests", "BucketSizeBytes", "BytesDownloaded", "BytesUploaded", "DeleteRequests", "FirstByteLatency", "GetRequests", "HeadRequests", "ListRequests", "NumberOfObjects", "PostRequests", "PutRequests", "SelectRequests", "SelectReturnedBytes", "SelectScannedBytes", "TotalRequestLatency"}, - "AWS/SDKMetrics": {"CallCount", "ClientErrorCount", "EndToEndLatency", "ConnectionErrorCount", "ServerErrorCount", "ThrottleCount"}, - "AWS/ServiceCatalog": {"ProvisionedProductLaunch"}, - "AWS/SES": {"Bounce", "Clicks", "Complaint", "Delivery", "Opens", "Reject", "Rendering Failures", "Reputation.BounceRate", "Reputation.ComplaintRate", "Send"}, - "AWS/SNS": {"NumberOfMessagesPublished", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed", "NumberOfNotificationsFilteredOut", "NumberOfNotificationsFilteredOut-InvalidAttributes", "NumberOfNotificationsFilteredOut-NoMessageAttributes", "PublishSize", "SMSMonthToDateSpentUSD", "SMSSuccessRate"}, - "AWS/SQS": {"ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesNotVisible", "ApproximateNumberOfMessagesVisible", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "NumberOfMessagesReceived", "NumberOfMessagesSent", "SentMessageSize"}, - "AWS/SWF": {"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ConsumedCapacity", "DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "PendingTasks", "ProvisionedBucketSize", "ProvisionedRefillRate", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat", "StartedDecisionTasksTimedOutOnClose", "ThrottledEvents", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut"}, - "AWS/SageMaker": {"CPUUtilization", "DatasetObjectsAutoAnnotated", "DatasetObjectsHumanAnnotated", "DatasetObjectsLabelingFailed", "DiskUtilization", "GPUMemoryUtilization", "GPUUtilization", "Invocation4XXErrors", "Invocation5XXErrors", "Invocations", "InvocationsPerInstance", "JobsFailed", "JobsStopped", "JobsSucceeded", "MemoryUtilization", "ModelLatency", "OverheadLatency", "TotalDatasetObjectsLabeled"}, - "AWS/States": {"ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesStarted", "ActivitiesSucceeded", "ActivitiesTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ConsumedCapacity", "ExecutionThrottled", "ExecutionTime", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut", "ProvisionedBucketSize", "ProvisionedRefillRate", "ThrottledEvents"}, - "AWS/StorageGateway": {"CacheFree", "CacheHitPercent", "CachePercentDirty", "CachePercentUsed", "CacheUsed", "CloudBytesDownloaded", "CloudBytesUploaded", "CloudDownloadLatency", "QueuedWrites", "ReadBytes", "ReadTime", "TimeSinceLastRecoveryPoint", "TotalCacheSize", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "WriteBytes", "WriteTime"}, - "AWS/Textract": {"ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, - "AWS/Timestream": {"SuccessfulRequestLatency", "SystemErrors", "UserErrors", "DataScannedBytes"}, - "AWS/ThingsGraph": {"EventStoreQueueSize", "FlowExecutionTime", "FlowExecutionsFailed", "FlowExecutionsStarted", "FlowExecutionsSucceeded", "FlowStepExecutionTime", "FlowStepExecutionsFailed", "FlowStepExecutionsStarted", "FlowStepExecutionsSucceeded"}, - "AWS/TransitGateway": {"BytesIn", "BytesOut", "PacketDropCountBlackhole", "PacketDropCountNoRoute", "PacketsIn", "PacketsOut"}, - "AWS/Transfer": {"BytesIn", "BytesOut", "FilesIn", "FilesOut"}, - "AWS/Translate": {"CharacterCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, - "AWS/TrustedAdvisor": {"GreenChecks", "RedChecks", "RedResources", "ServiceLimitUsage", "YellowChecks", "YellowResources"}, - "AWS/Usage": {"CallCount", "ResourceCount"}, - "AWS/VPN": {"TunnelDataIn", "TunnelDataOut", "TunnelState"}, - "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, - "AWS/WAFV2": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, - "AWS/WorkSpaces": {"Available", "ConnectionAttempt", "ConnectionFailure", "ConnectionSuccess", "InSessionLatency", "Maintenance", "SessionDisconnect", "SessionLaunchTime", "Stopped", "Unhealthy", "UserConnected"}, - "ECS/ContainerInsights": {"ContainerInstanceCount", "CpuUtilized", "CpuReserved", "DeploymentCount", "DesiredTaskCount", "MemoryUtilized", "MemoryReserved", "NetworkRxBytes", "NetworkTxBytes", "PendingTaskCount", "RunningTaskCount", "ServiceCount", "StorageReadBytes", "StorageWriteBytes", "TaskCount", "TaskSetCount", "instance_cpu_limit", "instance_cpu_reserved_capacity", "instance_cpu_usage_total", "instance_cpu_utilization", "instance_filesystem_utilization", "instance_memory_limit", "instance_memory_reserved_capacity", "instance_memory_utliization", "instance_memory_working_set", "instance_network_total_bytes", "instance_number_of_running_tasks"}, - "ContainerInsights": {"cluster_failed_node_count", "cluster_node_count", "namespace_number_of_running_pods", "node_cpu_limit", "node_cpu_reserved_capacity", "node_cpu_usage_total", "node_cpu_utilization", "node_filesystem_utilization", "node_memory_limit", "node_memory_reserved_capacity", "node_memory_utilization", "node_memory_working_set", "node_network_total_bytes", "node_number_of_running_containers", "node_number_of_running_pods", "pod_cpu_reserved_capacity", "pod_cpu_utilization", "pod_cpu_utilization_over_pod_limit", "pod_memory_reserved_capacity", "pod_memory_utilization", "pod_memory_utilization_over_pod_limit", "pod_number_of_container_restarts", "pod_network_rx_bytes", "pod_network_tx_bytes", "service_number_of_running_pods"}, - "AWS/Rekognition": {"DetectedFaceCount", "DetectedLabelCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, - "AWS/Cassandra": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "ReturnedItemCount", "ReturnedItemCountBySelect", "SuccessfulRequestCount", "SuccessfulRequestLatency", "SystemErrors", "UserErrors"}, - "AWS/AmplifyHosting": {"Requests", "BytesDownloaded", "BytesUploaded", "4XXErrors", "5XXErrors", "Latency"}, + "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"}, + "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", "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"}, + "AWS/GlobalAccelerator": {"NewFlowCount", "ProcessedBytesIn", "ProcessedBytesOut", "HealthyEndpointCount", "UnhealthyEndpointCount"}, + "AWS/Glue": {"glue.driver.BlockManager.disk.diskSpaceUsed_MB", "glue.driver.ExecutorAllocationManager.executors.numberAllExecutors", "glue.driver.ExecutorAllocationManager.executors.numberMaxNeededExecutors", "glue.driver.aggregate.bytesRead", "glue.driver.aggregate.elapsedTime", "glue.driver.aggregate.numCompletedStages", "glue.driver.aggregate.numCompletedTasks", "glue.driver.aggregate.numFailedTasks", "glue.driver.aggregate.numKilledTasks", "glue.driver.aggregate.recordsRead", "glue.driver.aggregate.shuffleBytesWritten", "glue.driver.aggregate.shuffleLocalBytesRead", "glue.driver.jvm.heap.usage glue.executorId.jvm.heap.usage glue.ALL.jvm.heap.usage", "glue.driver.jvm.heap.used glue.executorId.jvm.heap.used glue.ALL.jvm.heap.used", "glue.driver.s3.filesystem.read_bytes glue.executorId.s3.filesystem.read_bytes glue.ALL.s3.filesystem.read_bytes", "glue.driver.s3.filesystem.write_bytes glue.executorId.s3.filesystem.write_bytes glue.ALL.s3.filesystem.write_bytes", "glue.driver.system.cpuSystemLoad glue.executorId.system.cpuSystemLoad glue.ALL.system.cpuSystemLoad"}, + "AWS/GroundStation": {"BitErrorRate", "BlockErrorRate", "ReceivedPower", "Es/N0"}, + "AWS/Inspector": {"TotalAssessmentRunFindings", "TotalAssessmentRuns", "TotalHealthyAgents", "TotalMatchingAgents"}, + "AWS/IVS": {"ConcurrentViews", "ConcurrentStreams", "LiveDeliveredTime", "LiveInputTime", "RecordedTime"}, + "AWS/IoT": {"CanceledJobExecutionCount", "CanceledJobExecutionTotalCount", "ClientError", "Connect.AuthError", "Connect.ClientError", "Connect.ServerError", "Connect.Success", "Connect.Throttle", "DeleteThingShadow.Accepted", "FailedJobExecutionCount", "FailedJobExecutionTotalCount", "Failure", "GetThingShadow.Accepted", "InProgressJobExecutionCount", "InProgressJobExecutionTotalCount", "NonCompliantResources", "NumLogBatchesFailedToPublishThrottled", "NumLogEventsFailedToPublishThrottled", "ParseError", "Ping.Success", "PublishIn.AuthError", "PublishIn.ClientError", "PublishIn.ServerError", "PublishIn.Success", "PublishIn.Throttle", "PublishOut.AuthError", "PublishOut.ClientError", "PublishOut.Success", "QueuedJobExecutionCount", "QueuedJobExecutionTotalCount", "RejectedJobExecutionCount", "RejectedJobExecutionTotalCount", "RemovedJobExecutionCount", "RemovedJobExecutionTotalCount", "ResourcesEvaluated", "RuleMessageThrottled", "RuleNotFound", "RulesExecuted", "ServerError", "Subscribe.AuthError", "Subscribe.ClientError", "Subscribe.ServerError", "Subscribe.Success", "Subscribe.Throttle", "SuccededJobExecutionCount", "SuccededJobExecutionTotalCount", "Success", "TopicMatch", "Unsubscribe.ClientError", "Unsubscribe.ServerError", "Unsubscribe.Success", "Unsubscribe.Throttle", "UpdateThingShadow.Accepted", "Violations", "ViolationsCleared", "ViolationsInvalidated"}, + "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"}, + "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"}, + "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"}, + "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"}, + "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"}, + "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"}, + "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "Duration", "Errors", "Invocations", "IteratorAge", "Throttles", "UnreservedConcurrentExecutions"}, + "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"}, + "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"}, + "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"}, + "AWS/ML": {"PredictCount", "PredictFailureCount"}, + "AWS/MediaConnect": {"ARQRecovered", "ARQRequests", "BitRate", "CATError", "CRCError", "Connected", "ConnectedOutputs", "ContinuityCounter", "Disconnections", "DroppedPackets", "FECPackets", "FECRecovered", "NotRecoveredPackets", "OutputConnected", "OutputDisconnections", "OverflowPackets", "PATError", "PCRAccuracyError", "PCRError", "PIDError", "PMTError", "PTSError", "PacketLossPercent", "RecoveredPackets", "RoundTripTime", "SourceARQRecovered", "SourceARQRequests", "SourceBitRate", "SourceCATError", "SourceCRCError", "SourceConnected", "SourceContinuityCounter", "SourceDisconnections", "SourceDroppedPackets", "SourceFECPackets", "SourceFECRecovered", "SourceNotRecoveredPackets", "SourceOverflowPackets", "SourcePATError", "SourcePCRAccuracyError", "SourcePCRError", "SourcePIDError", "SourcePMTError", "SourcePTSError", "SourcePacketLossPercent", "SourceRecoveredPackets", "SourceRoundTripTime", "SourceTSByteError", "SourceTSSyncLoss", "SourceTotalPackets", "SourceTransportError", "TSByteError", "TSSyncLoss", "TotalPackets", "TransportError"}, + "AWS/MediaConvert": {"AudioOutputSeconds", "Errors", "HDOutputSeconds", "JobsCompletedCount", "JobsErroredCount", "SDOutputSeconds", "StandbyTime", "TranscodingTime", "UHDOutputSeconds"}, + "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"}, + "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"}, + "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"}, + "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"}, + "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"}, + "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, + "AWS/GatewayELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "UnHealthyHostCount"}, + "AWS/NetworkFirewall": {"DroppedPackets", "Packets", "PassedPackets", "ReceivedPacketCount"}, + "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_steal", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_15", "load_5", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, + "AWS/Polly": {"2XXCount", "4XXCount", "5XXCount", "RequestCharacters", "ResponseLatency"}, + "AWS/PrivateLinkEndpoints": {"ActiveConnections", "BytesProcessed", "NewConnections", "PacketsDropped", "RstPacketsReceived"}, + "AWS/PrivateLinkServices": {"ActiveConnections", "BytesProcessed", "EndpointsCount", "NewConnections", "RstPacketsReceived"}, + "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraGlobalDBDataTransferBytes", "AuroraGlobalDBReplicatedWriteIO", "AuroraGlobalDBReplicationLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "AvailabilityPercentage", "BacktrackChangeRecordsCreationRate", "BacktrackChangeRecordsStored", "BacktrackWindowActual", "BacktrackWindowAlert", "BackupRetentionPeriodStorageUsed", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "ClientConnections", "ClientConnectionsClosed", "ClientConnectionsNoTLS", "ClientConnectionsReceived", "ClientConnectionsSetupFailedAuth", "ClientConnectionsSetupSucceeded", "ClientConnectionsTLS", "CommitLatency", "CommitThroughput", "DDLLatency", "DDLThroughput", "DMLLatency", "DMLThroughput", "DatabaseConnectionRequests", "DatabaseConnectionRequestsWithTLS", "DatabaseConnections", "DatabaseConnectionsBorrowLatency", "DatabaseConnectionsCurrentlyBorrowed", "DatabaseConnectionsCurrentlyInTransaction", "DatabaseConnectionsCurrentlySessionPinned", "DatabaseConnectionsSetupFailed", "DatabaseConnectionsSetupSucceeded", "DatabaseConnectionsWithTLS", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "EngineUptime", "FailedSQLServerAgentJobsCount", "FreeLocalStorage", "FreeStorageSpace", "FreeableMemory", "InsertLatency", "InsertThroughput", "LoginFailures", "MaxDatabaseConnectionsAllowed", "MaximumUsedTransactionIDs", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "OldestReplicationSlotLag", "Queries", "QueryDatabaseResponseLatency", "QueryRequests", "QueryRequestsNoTLS", "QueryRequestsTLS", "QueryResponseLatency", "RDSToAuroraPostgreSQLReplicaLag", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ReplicationSlotDiskUsage", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SnapshotStorageUsed", "SwapUsage", "TotalBackupStorageBilled", "TransactionLogsDiskUsage", "TransactionLogsGeneration", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/Redshift": {"CommitQueueLength", "ConcurrencyScalingActiveClusters", "ConcurrencyScalingSeconds", "CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "MaxConfiguredConcurrencyScalingClusters", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "TotalTableCount", "WLMQueueLength", "WLMQueueWaitTime", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMRunningQueries", "WriteIOPS", "WriteLatency", "WriteThroughput", "SchemaQuota", "NumExceededSchemaQuotas", "StorageUsed", "PercentageQuotaUsed"}, + "AWS/Robomaker": {"RealTimeFactor", "vCPU", "Memory", "SimulationUnit"}, + "AWS/Route53": {"ChildHealthCheckHealthyCount", "ConnectionTime", "DNSQueries", "HealthCheckPercentageHealthy", "HealthCheckStatus", "SSLHandshakeTime", "TimeToFirstByte"}, + "AWS/Route53Resolver": {"InboundQueryVolume", "OutboundQueryVolume", "OutboundQueryAggregatedVolume"}, + "AWS/S3": {"4xxErrors", "5xxErrors", "AllRequests", "BucketSizeBytes", "BytesDownloaded", "BytesUploaded", "DeleteRequests", "FirstByteLatency", "GetRequests", "HeadRequests", "ListRequests", "NumberOfObjects", "PostRequests", "PutRequests", "SelectRequests", "SelectReturnedBytes", "SelectScannedBytes", "TotalRequestLatency"}, + "AWS/SDKMetrics": {"CallCount", "ClientErrorCount", "EndToEndLatency", "ConnectionErrorCount", "ServerErrorCount", "ThrottleCount"}, + "AWS/ServiceCatalog": {"ProvisionedProductLaunch"}, + "AWS/SES": {"Bounce", "Clicks", "Complaint", "Delivery", "Opens", "Reject", "Rendering Failures", "Reputation.BounceRate", "Reputation.ComplaintRate", "Send"}, + "AWS/SNS": {"NumberOfMessagesPublished", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed", "NumberOfNotificationsFilteredOut", "NumberOfNotificationsFilteredOut-InvalidAttributes", "NumberOfNotificationsFilteredOut-NoMessageAttributes", "PublishSize", "SMSMonthToDateSpentUSD", "SMSSuccessRate"}, + "AWS/SQS": {"ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesNotVisible", "ApproximateNumberOfMessagesVisible", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "NumberOfMessagesReceived", "NumberOfMessagesSent", "SentMessageSize"}, + "AWS/SWF": {"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ConsumedCapacity", "DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "PendingTasks", "ProvisionedBucketSize", "ProvisionedRefillRate", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat", "StartedDecisionTasksTimedOutOnClose", "ThrottledEvents", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut"}, + "AWS/SageMaker": {"CPUUtilization", "DatasetObjectsAutoAnnotated", "DatasetObjectsHumanAnnotated", "DatasetObjectsLabelingFailed", "DiskUtilization", "GPUMemoryUtilization", "GPUUtilization", "Invocation4XXErrors", "Invocation5XXErrors", "Invocations", "InvocationsPerInstance", "JobsFailed", "JobsStopped", "JobsSucceeded", "MemoryUtilization", "ModelLatency", "OverheadLatency", "TotalDatasetObjectsLabeled"}, + "AWS/States": {"ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesStarted", "ActivitiesSucceeded", "ActivitiesTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ConsumedCapacity", "ExecutionThrottled", "ExecutionTime", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut", "ProvisionedBucketSize", "ProvisionedRefillRate", "ThrottledEvents"}, + "AWS/StorageGateway": {"CacheFree", "CacheHitPercent", "CachePercentDirty", "CachePercentUsed", "CacheUsed", "CloudBytesDownloaded", "CloudBytesUploaded", "CloudDownloadLatency", "QueuedWrites", "ReadBytes", "ReadTime", "TimeSinceLastRecoveryPoint", "TotalCacheSize", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "WriteBytes", "WriteTime"}, + "AWS/Textract": {"ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, + "AWS/Timestream": {"SuccessfulRequestLatency", "SystemErrors", "UserErrors", "DataScannedBytes"}, + "AWS/ThingsGraph": {"EventStoreQueueSize", "FlowExecutionTime", "FlowExecutionsFailed", "FlowExecutionsStarted", "FlowExecutionsSucceeded", "FlowStepExecutionTime", "FlowStepExecutionsFailed", "FlowStepExecutionsStarted", "FlowStepExecutionsSucceeded"}, + "AWS/TransitGateway": {"BytesIn", "BytesOut", "PacketDropCountBlackhole", "PacketDropCountNoRoute", "PacketsIn", "PacketsOut"}, + "AWS/Transfer": {"BytesIn", "BytesOut", "FilesIn", "FilesOut"}, + "AWS/Translate": {"CharacterCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, + "AWS/TrustedAdvisor": {"GreenChecks", "RedChecks", "RedResources", "ServiceLimitUsage", "YellowChecks", "YellowResources"}, + "AWS/Usage": {"CallCount", "ResourceCount"}, + "AWS/VPN": {"TunnelDataIn", "TunnelDataOut", "TunnelState"}, + "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, + "AWS/WAFV2": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, + "AWS/WorkSpaces": {"Available", "ConnectionAttempt", "ConnectionFailure", "ConnectionSuccess", "InSessionLatency", "Maintenance", "SessionDisconnect", "SessionLaunchTime", "Stopped", "Unhealthy", "UserConnected"}, + "ECS/ContainerInsights": {"ContainerInstanceCount", "CpuUtilized", "CpuReserved", "DeploymentCount", "DesiredTaskCount", "MemoryUtilized", "MemoryReserved", "NetworkRxBytes", "NetworkTxBytes", "PendingTaskCount", "RunningTaskCount", "ServiceCount", "StorageReadBytes", "StorageWriteBytes", "TaskCount", "TaskSetCount", "instance_cpu_limit", "instance_cpu_reserved_capacity", "instance_cpu_usage_total", "instance_cpu_utilization", "instance_filesystem_utilization", "instance_memory_limit", "instance_memory_reserved_capacity", "instance_memory_utliization", "instance_memory_working_set", "instance_network_total_bytes", "instance_number_of_running_tasks"}, + "ContainerInsights": {"cluster_failed_node_count", "cluster_node_count", "namespace_number_of_running_pods", "node_cpu_limit", "node_cpu_reserved_capacity", "node_cpu_usage_total", "node_cpu_utilization", "node_filesystem_utilization", "node_memory_limit", "node_memory_reserved_capacity", "node_memory_utilization", "node_memory_working_set", "node_network_total_bytes", "node_number_of_running_containers", "node_number_of_running_pods", "pod_cpu_reserved_capacity", "pod_cpu_utilization", "pod_cpu_utilization_over_pod_limit", "pod_memory_reserved_capacity", "pod_memory_utilization", "pod_memory_utilization_over_pod_limit", "pod_number_of_container_restarts", "pod_network_rx_bytes", "pod_network_tx_bytes", "service_number_of_running_pods"}, + "AWS/Rekognition": {"DetectedFaceCount", "DetectedLabelCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, + "AWS/Cassandra": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "ReturnedItemCount", "ReturnedItemCountBySelect", "SuccessfulRequestCount", "SuccessfulRequestLatency", "SystemErrors", "UserErrors"}, + "AWS/AmplifyHosting": {"Requests", "BytesDownloaded", "BytesUploaded", "4XXErrors", "5XXErrors", "Latency"}, } var dimensionsMap = map[string][]string{ @@ -473,6 +475,8 @@ var dimensionsMap = map[string][]string{ "AWS/NetworkFirewall": {"AvailabilityZone", "CustomAction", "Engine", "FirewallName"}, "AWS/OpsWorks": {"InstanceId", "LayerId", "StackId"}, "AWS/Polly": {"Operation"}, + "AWS/PrivateLinkEndpoints": {"Endpoint Type", "Service Name", "Subnet Id", "VPC Endpoint Id", "VPC Id"}, + "AWS/PrivateLinkServices": {"Az", "Load Balancer Arn", "Service Id", "VPC Endpoint Id"}, "AWS/RDS": {"DBClusterIdentifier", "DBInstanceIdentifier", "DatabaseClass", "DbClusterIdentifier", "EngineName", "ProxyName", "Role", "SourceRegion", "Target", "TargetGroup", "TargetRole"}, "AWS/Redshift": {"ClusterIdentifier", "NodeID", "service class", "stage", "latency", "wlmid"}, "AWS/Robomaker": {"SimulationJobId"}, diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go new file mode 100644 index 00000000000..c2f76249748 --- /dev/null +++ b/pkg/tsdb/cloudwatch/resource_handler.go @@ -0,0 +1,59 @@ +package cloudwatch + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" +) + +func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/regions", handleResourceReq(e.handleGetRegions)) + mux.HandleFunc("/namespaces", handleResourceReq(e.handleGetNamespaces)) + mux.HandleFunc("/metrics", handleResourceReq(e.handleGetMetrics)) + mux.HandleFunc("/all-metrics", handleResourceReq(e.handleGetAllMetrics)) + mux.HandleFunc("/dimension-keys", handleResourceReq(e.handleGetDimensionKeys)) + mux.HandleFunc("/dimension-values", handleResourceReq(e.handleGetDimensionValues)) + mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds)) + mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute)) + mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns)) + return mux +} + +type handleFn func(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) + +func handleResourceReq(handleFunc handleFn) func(rw http.ResponseWriter, req *http.Request) { + return func(rw http.ResponseWriter, req *http.Request) { + ctx := req.Context() + pluginContext := httpadapter.PluginConfigFromContext(ctx) + err := req.ParseForm() + if err != nil { + writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) + } + data, err := handleFunc(pluginContext, req.URL.Query()) + if err != nil { + writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) + } + body, err := json.Marshal(data) + if err != nil { + writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err)) + } + rw.WriteHeader(http.StatusOK) + _, err = rw.Write(body) + if err != nil { + plog.Error("Unable to write HTTP response", "error", err) + } + } +} + +func writeResponse(rw http.ResponseWriter, code int, msg string) { + rw.WriteHeader(code) + _, err := rw.Write([]byte(msg)) + if err != nil { + plog.Error("Unable to write HTTP response", "error", err) + } +} diff --git a/pkg/tsdb/influxdb/response_parser.go b/pkg/tsdb/influxdb/response_parser.go index f38e69fbe25..19013a315c6 100644 --- a/pkg/tsdb/influxdb/response_parser.go +++ b/pkg/tsdb/influxdb/response_parser.go @@ -56,32 +56,77 @@ func parseJSON(buf io.ReadCloser) (Response, error) { func transformRows(rows []Row, query Query) data.Frames { frames := data.Frames{} for _, row := range rows { - for columnIndex, column := range row.Columns { - if column == "time" { - continue - } + var hasTimeCol = false - var timeArray []time.Time - var valueArray []*float64 + for _, column := range row.Columns { + if strings.ToLower(column) == "time" { + hasTimeCol = true + } + } + + if !hasTimeCol { + var values []string for _, valuePair := range row.Values { - timestamp, timestampErr := parseTimestamp(valuePair[0]) - // we only add this row if the timestamp is valid - if timestampErr == nil { - value := parseValue(valuePair[columnIndex]) - timeArray = append(timeArray, timestamp) - valueArray = append(valueArray, value) + if strings.Contains(strings.ToLower(query.RawQuery), strings.ToLower("SHOW TAG VALUES")) { + if len(valuePair) >= 2 { + values = append(values, valuePair[1].(string)) + } + } else { + if len(valuePair) >= 1 { + values = append(values, valuePair[0].(string)) + } } } - name := formatFrameName(row, column, query) - timeField := data.NewField("time", nil, timeArray) - valueField := data.NewField("value", row.Tags, valueArray) + field := data.NewField("value", nil, values) + frames = append(frames, data.NewFrame(row.Name, field)) + } else { + for colIndex, column := range row.Columns { + if column == "time" { + continue + } - // set a nice name on the value-field - valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + var timeArray []time.Time + var floatArray []*float64 + var stringArray []string + var boolArray []bool + valType := typeof(row.Values[0][colIndex]) + name := formatFrameName(row, column, query) - frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + for _, valuePair := range row.Values { + timestamp, timestampErr := parseTimestamp(valuePair[0]) + // we only add this row if the timestamp is valid + if timestampErr == nil { + timeArray = append(timeArray, timestamp) + if valType == "string" { + value := valuePair[colIndex].(string) + stringArray = append(stringArray, value) + } else if valType == "json.Number" { + value := parseNumber(valuePair[colIndex]) + floatArray = append(floatArray, value) + } else if valType == "bool" { + value := valuePair[colIndex].(bool) + boolArray = append(boolArray, value) + } + } + } + + timeField := data.NewField("time", nil, timeArray) + if valType == "string" { + valueField := data.NewField("value", row.Tags, stringArray) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } else if valType == "json.Number" { + valueField := data.NewField("value", row.Tags, floatArray) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } else if valType == "bool" { + valueField := data.NewField("value", row.Tags, boolArray) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } + } } } @@ -168,21 +213,15 @@ func parseTimestamp(value interface{}) (time.Time, error) { return t, nil } -func parseValue(value interface{}) *float64 { +func typeof(v interface{}) string { + return fmt.Sprintf("%T", v) +} + +func parseNumber(value interface{}) *float64 { // NOTE: we use pointers-to-float64 because we need // to represent null-json-values. they come for example // when we do a group-by with fill(null) - // FIXME: the value of an influxdb-query can be: - // - string - // - float - // - integer - // - boolean - // - // here we only handle numeric values. this is probably - // enough for alerting, but later if we want to support - // arbitrary queries, we will have to improve the code - if value == nil { // this is what json-nulls become return nil diff --git a/pkg/tsdb/influxdb/response_parser_test.go b/pkg/tsdb/influxdb/response_parser_test.go index e47c6b4cae7..c96aba76f08 100644 --- a/pkg/tsdb/influxdb/response_parser_test.go +++ b/pkg/tsdb/influxdb/response_parser_test.go @@ -51,12 +51,12 @@ func TestInfluxdbResponseParser(t *testing.T) { "series": [ { "name": "cpu", - "columns": ["time","mean","sum"], + "columns": ["time","mean","path","isActive"], "tags": {"datacenter": "America"}, "values": [ - [111,222,333], - [111,222,333], - [111,null,333] + [111,222,"/usr/path",true], + [111,222,"/usr/path",false], + [111,null,"/usr/path",true] ] } ] @@ -68,24 +68,140 @@ func TestInfluxdbResponseParser(t *testing.T) { query := &Query{} labels, err := data.LabelsFromString("datacenter=America") require.Nil(t, err) - newField := data.NewField("value", labels, []*float64{ + + floatField := data.NewField("value", labels, []*float64{ pointer.Float64(222), pointer.Float64(222), nil, }) - newField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.mean { datacenter: America }"} - testFrame := data.NewFrame("cpu.mean { datacenter: America }", + floatField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.mean { datacenter: America }"} + floatFrame := data.NewFrame("cpu.mean { datacenter: America }", data.NewField("time", nil, []time.Time{ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), }), - newField, + floatField, ) - testFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} + floatFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} + + stringField := data.NewField("value", labels, []string{ + "/usr/path", "/usr/path", "/usr/path", + }) + stringField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.path { datacenter: America }"} + stringFrame := data.NewFrame("cpu.path { datacenter: America }", + data.NewField("time", nil, + []time.Time{ + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + }), + stringField, + ) + stringFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} + + boolField := data.NewField("value", labels, []bool{ + true, false, true, + }) + boolField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.isActive { datacenter: America }"} + boolFrame := data.NewFrame("cpu.isActive { datacenter: America }", + data.NewField("time", nil, + []time.Time{ + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + }), + boolField, + ) + boolFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} result := parser.Parse(prepare(response), addQueryToQueries(*query)) frame := result.Responses["A"] + if diff := cmp.Diff(floatFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(stringFrame, frame.Frames[1], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(boolFrame, frame.Frames[2], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("Influxdb response parser should parse metricFindQueries normally", func(t *testing.T) { + parser := &ResponseParser{} + + response := ` + { + "results": [ + { + "series": [ + { + "refId": "metricFindQuery", + "name": "cpu", + "values": [ + ["cpu"], + ["disk"], + ["logs"] + ] + } + ] + } + ] + } + ` + + var queries []Query + queries = append(queries, Query{RefID: "metricFindQuery"}) + newField := data.NewField("value", nil, []string{ + "cpu", "disk", "logs", + }) + testFrame := data.NewFrame("cpu", + newField, + ) + + result := parser.Parse(prepare(response), queries) + + frame := result.Responses["metricFindQuery"] + if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("Influxdb response parser should parse metricFindQueries->SHOW TAG VALUES normally", func(t *testing.T) { + parser := &ResponseParser{} + + response := ` + { + "results": [ + { + "series": [ + { + "name": "cpu", + "values": [ + ["values", "cpu-total"], + ["values", "cpu0"], + ["values", "cpu1"] + ] + } + ] + } + ] + } + ` + + var queries []Query + queries = append(queries, Query{RawQuery: "SHOW TAG VALUES", RefID: "metricFindQuery"}) + newField := data.NewField("value", nil, []string{ + "cpu-total", "cpu0", "cpu1", + }) + testFrame := data.NewFrame("cpu", + newField, + ) + + result := parser.Parse(prepare(response), queries) + + frame := result.Responses["metricFindQuery"] if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) } @@ -130,7 +246,12 @@ func TestInfluxdbResponseParser(t *testing.T) { "series": [ { "name": "cpu", - "columns": ["time","mean"] + "columns": ["time","cpu"], + "values": [ + ["values", "cpu-total"], + ["values", "cpu0"], + ["values", "cpu1"] + ] } ] } @@ -570,18 +691,18 @@ func TestInfluxdbResponseParser(t *testing.T) { require.EqualError(t, result.Responses["A"].Error, "error parsing query: found THING") }) - t.Run("Influxdb response parser parseValue nil", func(t *testing.T) { - value := parseValue(nil) + t.Run("Influxdb response parser parseNumber nil", func(t *testing.T) { + value := parseNumber(nil) require.Nil(t, value) }) - t.Run("Influxdb response parser parseValue valid JSON.number", func(t *testing.T) { - value := parseValue(json.Number("95.4")) + t.Run("Influxdb response parser parseNumber valid JSON.number", func(t *testing.T) { + value := parseNumber(json.Number("95.4")) require.Equal(t, *value, 95.4) }) - t.Run("Influxdb response parser parseValue invalid type", func(t *testing.T) { - value := parseValue("95.4") + t.Run("Influxdb response parser parseNumber invalid type", func(t *testing.T) { + value := parseNumber("95.4") require.Nil(t, value) }) @@ -593,7 +714,7 @@ func TestInfluxdbResponseParser(t *testing.T) { require.Equal(t, timestamp.Format(time.RFC3339), "2021-01-02T03:04:05Z") }) - t.Run("Influxdb response parser parseValue invalid type", func(t *testing.T) { + t.Run("Influxdb response parser parseNumber invalid type", func(t *testing.T) { _, err := parseTimestamp("hello") require.Error(t, err) }) diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index ccd402b25c6..f0b626671b9 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -36,7 +37,7 @@ func TestHandleRequest(t *testing.T) { return backend.NewQueryDataResponse(), nil } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) s := ProvideService(client, nil, dsService) ds := &models.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go new file mode 100644 index 00000000000..9168f4e9cd1 --- /dev/null +++ b/pkg/tsdb/loki/api.go @@ -0,0 +1,134 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/loki/pkg/loghttp" + jsoniter "github.com/json-iterator/go" +) + +type LokiAPI struct { + client *http.Client + url string + log log.Logger +} + +func newLokiAPI(client *http.Client, url string, log log.Logger) *LokiAPI { + return &LokiAPI{client: client, url: url, log: log} +} + +func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http.Request, error) { + qs := url.Values{} + qs.Set("query", query.Expr) + qs.Set("step", query.Step.String()) + qs.Set("start", strconv.FormatInt(query.Start.UnixNano(), 10)) + qs.Set("end", strconv.FormatInt(query.End.UnixNano(), 10)) + + lokiUrl, err := url.Parse(lokiDsUrl) + if err != nil { + return nil, err + } + + lokiUrl.Path = "/loki/api/v1/query_range" + lokiUrl.RawQuery = qs.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", lokiUrl.String(), nil) + if err != nil { + return nil, err + } + + // NOTE: + // 1. we are missing "dynamic" http params, like OAuth data. + // this never worked before (and it is not needed for alerting scenarios), + // so it is not a regression. + // twe need to have that when we migrate to backend-queries. + // + // 2. we will have to send a custom http header based on the VolumeQuery prop + // (again, not needed for the alerting scenario) + // if query.VolumeQuery { + // req.Header.Set("X-Query-Tags", "Source=logvolhist") + // } + + return req, nil +} + +type lokiError struct { + Message string +} + +// we know there is an error, +// based on the http-response-body +// we have to make an informative error-object +func makeLokiError(body io.ReadCloser) error { + var buf bytes.Buffer + _, err := buf.ReadFrom(body) + if err != nil { + return err + } + + bytes := buf.Bytes() + + // the error-message is probably a JSON structure, + // with a string-field named "message". we want the + // value of that field. + // but, the response might be just a simple string, + // this was used in older Loki versions. + // so our approach is this: + // - we try to convert the bytes to JSON + // - we take the value of the field "message" + // - if any of these steps fail, or if "message" is empty, we return the whole text + + var data lokiError + err = json.Unmarshal(bytes, &data) + if err != nil { + // we were unable to convert the bytes to JSON, we return the whole text + return fmt.Errorf("%v", string(bytes)) + } + + errorMessage := data.Message + + if errorMessage == "" { + // we got no usable error message, we return the whole text + return fmt.Errorf("%v", string(bytes)) + } + + return fmt.Errorf("%v", errorMessage) +} + +func (api *LokiAPI) QueryRange(ctx context.Context, query lokiQuery) (*loghttp.QueryResponse, error) { + req, err := makeRequest(ctx, api.url, query) + if err != nil { + return nil, err + } + + resp, err := api.client.Do(req) + if err != nil { + return nil, err + } + + defer func() { + if err := resp.Body.Close(); err != nil { + api.log.Warn("Failed to close response body", "err", err) + } + }() + + if resp.StatusCode/100 != 2 { + return nil, makeLokiError(resp.Body) + } + + var response loghttp.QueryResponse + err = jsoniter.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return &response, nil +} diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index acad25928f7..a77ae17c54f 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -2,6 +2,7 @@ package loki import ( "bytes" + "context" "io/ioutil" "net/http" "os" @@ -11,7 +12,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/grafana/loki/pkg/logcli/client" + "github.com/grafana/grafana/pkg/infra/log" "github.com/stretchr/testify/require" ) @@ -42,7 +43,7 @@ func TestSuccessResponse(t *testing.T) { bytes, err := os.ReadFile(responseFileName) require.NoError(t, err) - frames, err := runQuery(makeMockedClient(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42}) + frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42}) require.NoError(t, err) dr := &backend.DataResponse{ @@ -60,16 +61,9 @@ func TestErrorResponse(t *testing.T) { // NOTE: when there is an error-response, it comes with // HTTP code 400, and the format seems to change between versions: // 2.3.x: content-type=text/plain, content is plaintext - // 2.4.0: content-type=application/json, content is plaintext !!! - // 2.4.1: same as 2.4.0 - // 2.4.2: same as 2.4.0 (2.4.2 is currently the latest) + // 2.4.x: content-type=application/json, content is plaintext: https://github.com/grafana/loki/issues/4844 // main-branch: content-type=application/json, content is JSON // we should always be able to to return some kind of error message - // - // also, the returned error message is not what we want to return - // to the user, but this is what is currently returned to the user, - // so the tests check for that. we will have to change this in the future. - tt := []struct { name string body []byte @@ -85,25 +79,31 @@ func TestErrorResponse(t *testing.T) { "message": "parse error at line 1, col 8: something is wrong" }`), contentType: "application/json; charset=utf-8", - errorMessage: "Run out of attempts while querying the server", + errorMessage: "parse error at line 1, col 8: something is wrong", }, { name: "parse a non-json error body with json content type (loki 2.4.0,2.4.1,2.4.2)", body: []byte("parse error at line 1, col 8: something is wrong"), contentType: "application/json; charset=UTF-8", - errorMessage: "Run out of attempts while querying the server", + errorMessage: "parse error at line 1, col 8: something is wrong", }, { name: "parse an error response in plain text", body: []byte("parse error at line 1, col 8: something is wrong"), contentType: "text/plain; charset=utf-8", - errorMessage: "Run out of attempts while querying the server", + errorMessage: "parse error at line 1, col 8: something is wrong", + }, + { + name: "parse an error response that is broken JSON", + body: []byte(`{"message":"error message but the JSON is not finished`), + contentType: "text/plain; charset=utf-8", + errorMessage: `{"message":"error message but the JSON is not finished`, }, } for _, test := range tt { t.Run(test.name, func(t *testing.T) { - frames, err := runQuery(makeMockedClient(400, test.contentType, test.body), &lokiQuery{}) + frames, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body), &lokiQuery{}) require.Len(t, frames, 0) require.Error(t, err) @@ -112,13 +112,13 @@ func TestErrorResponse(t *testing.T) { } } -type MockedRoundTripper struct { +type mockedRoundTripper struct { statusCode int responseBytes []byte contentType string } -func (mockedRT *MockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { +func (mockedRT *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { header := http.Header{} header.Add("Content-Type", mockedRT.contentType) return &http.Response{ @@ -128,13 +128,10 @@ func (mockedRT *MockedRoundTripper) RoundTrip(req *http.Request) (*http.Response }, nil } -func makeMockedClient(statusCode int, contentType string, responseBytes []byte) *client.DefaultClient { - client := &client.DefaultClient{ - Address: "http://localhost:9999", - Tripperware: func(t http.RoundTripper) http.RoundTripper { - return &MockedRoundTripper{statusCode: statusCode, responseBytes: responseBytes, contentType: contentType} - }, +func makeMockedAPI(statusCode int, contentType string, responseBytes []byte) *LokiAPI { + client := http.Client{ + Transport: &mockedRoundTripper{statusCode: statusCode, contentType: contentType, responseBytes: responseBytes}, } - return client + return newLokiAPI(&client, "http://localhost:9999", log.New("test")) } diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index feb95716f52..69db994b5d4 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -2,8 +2,6 @@ package loki import ( "context" - "crypto/tls" - "encoding/json" "fmt" "net/http" "regexp" @@ -17,12 +15,9 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/loki/pkg/logcli/client" "github.com/grafana/loki/pkg/loghttp" - "github.com/grafana/loki/pkg/logproto" "go.opentelemetry.io/otel/attribute" - "github.com/prometheus/common/config" "github.com/prometheus/common/model" ) @@ -45,12 +40,8 @@ var ( ) type datasourceInfo struct { - HTTPClient *http.Client - URL string - TLSClientConfig *tls.Config - BasicAuthUser string - BasicAuthPassword string - TimeInterval string `json:"timeInterval"` + HTTPClient *http.Client + URL string } type QueryModel struct { @@ -74,24 +65,9 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst return nil, err } - tlsClientConfig, err := httpClientProvider.GetTLSConfig(opts) - if err != nil { - return nil, err - } - - jsonData := datasourceInfo{} - err = json.Unmarshal(settings.JSONData, &jsonData) - if err != nil { - return nil, fmt.Errorf("error reading settings: %w", err) - } - model := &datasourceInfo{ - HTTPClient: client, - URL: settings.URL, - TLSClientConfig: tlsClientConfig, - TimeInterval: jsonData.TimeInterval, - BasicAuthUser: settings.BasicAuthUser, - BasicAuthPassword: settings.DecryptedSecureJSONData["basicAuthPassword"], + HTTPClient: client, + URL: settings.URL, } return model, nil } @@ -105,17 +81,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return result, err } - client := &client.DefaultClient{ - Address: dsInfo.URL, - Username: dsInfo.BasicAuthUser, - Password: dsInfo.BasicAuthPassword, - TLSConfig: config.TLSConfig{ - InsecureSkipVerify: dsInfo.TLSClientConfig.InsecureSkipVerify, - }, - Tripperware: func(t http.RoundTripper) http.RoundTripper { - return dsInfo.HTTPClient.Transport - }, - } + api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, s.plog) queries, err := parseQuery(req) if err != nil { @@ -130,7 +96,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) span.SetAttributes("stop_unixnano", query.End, attribute.Key("stop_unixnano").Int64(query.End.UnixNano())) defer span.End() - frames, err := runQuery(client, query) + frames, err := runQuery(ctx, api, query) queryRes := backend.DataResponse{} @@ -204,15 +170,8 @@ func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, } // we extracted this part of the functionality to make it easy to unit-test it -func runQuery(client *client.DefaultClient, query *lokiQuery) (data.Frames, error) { - // `limit` only applies to log-producing queries, and we - // currently only support metric queries, so this can be set to any value. - limit := 1 - - // we do not use `interval`, so we set it to zero - interval := time.Duration(0) - - value, err := client.QueryRange(query.Expr, limit, query.Start, query.End, logproto.BACKWARD, query.Step, interval, false) +func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery) (data.Frames, error) { + value, err := api.QueryRange(ctx, *query) if err != nil { return data.Frames{}, err } diff --git a/pkg/tsdb/loki/loki_bench_test.go b/pkg/tsdb/loki/loki_bench_test.go index fec2eefc29e..331b8b533f1 100644 --- a/pkg/tsdb/loki/loki_bench_test.go +++ b/pkg/tsdb/loki/loki_bench_test.go @@ -1,6 +1,7 @@ package loki import ( + "context" "fmt" "math/rand" "strings" @@ -15,7 +16,7 @@ func BenchmarkMatrixJson(b *testing.B) { b.ResetTimer() for n := 0; n < b.N; n++ { - _, _ = runQuery(makeMockedClient(200, "application/json", bytes), &lokiQuery{}) + _, _ = runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{}) } } diff --git a/pkg/tsdb/prometheus/time_series_query.go b/pkg/tsdb/prometheus/time_series_query.go index b38ffd33d8b..a4d1b57e168 100644 --- a/pkg/tsdb/prometheus/time_series_query.go +++ b/pkg/tsdb/prometheus/time_series_query.go @@ -39,6 +39,8 @@ const ( varRateIntervalAlt = "${__rate_interval}" ) +const legendFormatAuto = "__auto" + type TimeSeriesQueryType string const ( @@ -137,11 +139,14 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query } func formatLegend(metric model.Metric, query *PrometheusQuery) string { - var legend string + var legend = metric.String() - if query.LegendFormat == "" { - legend = metric.String() - } else { + if query.LegendFormat == legendFormatAuto { + // If we have labels set legend to empty string to utilize the auto naming system + if len(metric) > 0 { + legend = "" + } + } else if query.LegendFormat != "" { result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { labelName := strings.Replace(string(in), "{{", "", 1) labelName = strings.Replace(labelName, "}}", "", 1) @@ -335,8 +340,12 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data timeField.Name = data.TimeSeriesTimeFieldName timeField.Config = &data.FieldConfig{Interval: float64(query.Step.Milliseconds())} valueField.Name = data.TimeSeriesValueFieldName - valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} valueField.Labels = tags + + if name != "" { + valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} + } + frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) } diff --git a/pkg/tsdb/prometheus/time_series_query_test.go b/pkg/tsdb/prometheus/time_series_query_test.go index fc50c3fb78f..f98d7484a8f 100644 --- a/pkg/tsdb/prometheus/time_series_query_test.go +++ b/pkg/tsdb/prometheus/time_series_query_test.go @@ -52,6 +52,30 @@ func TestPrometheus_timeSeriesQuery_formatLeged(t *testing.T) { require.Equal(t, `{job="grafana"}`, formatLegend(metric, query)) }) + + t.Run("When legendFormat = __auto and no labels", func(t *testing.T) { + metric := map[p.LabelName]p.LabelValue{} + + query := &PrometheusQuery{ + LegendFormat: legendFormatAuto, + Expr: `{job="grafana"}`, + } + + require.Equal(t, `{job="grafana"}`, formatLegend(metric, query)) + }) + + t.Run("When legendFormat = __auto with labels", func(t *testing.T) { + metric := map[p.LabelName]p.LabelValue{ + p.LabelName("app"): p.LabelValue("backend"), + } + + query := &PrometheusQuery{ + LegendFormat: legendFormatAuto, + Expr: `{job="grafana"}`, + } + + require.Equal(t, "", formatLegend(metric, query)) + }) } func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 4bb5b60ce7f..72eecdee141 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -15,15 +15,16 @@ import { GrafanaRoute } from './core/navigation/GrafanaRoute'; import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList'; import { SearchWrapper } from 'app/features/search'; import { LiveConnectionWarning } from './features/live/LiveConnectionWarning'; -import { AngularRoot } from './angular/AngularRoot'; import { I18nProvider } from './core/localisation'; +import { AngularRoot } from './angular/AngularRoot'; +import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled'; interface AppWrapperProps { app: GrafanaApp; } interface AppWrapperState { - ngInjector: any; + ready?: boolean; } /** Used by enterprise */ @@ -39,27 +40,14 @@ export function addPageBanner(fn: ComponentType) { } export class AppWrapper extends React.Component { - container = React.createRef(); - constructor(props: AppWrapperProps) { super(props); - - this.state = { - ngInjector: null, - }; + this.state = {}; } - componentDidMount() { - if (this.container) { - this.bootstrapNgApp(); - } else { - throw new Error('Failed to boot angular app, no container to attach to'); - } - } - - bootstrapNgApp() { - const injector = this.props.app.angularApp.bootstrap(); - this.setState({ ngInjector: injector }); + async componentDidMount() { + await loadAndInitAngularIfEnabled(); + this.setState({ ready: true }); $('.preloader').remove(); } @@ -91,6 +79,8 @@ export class AppWrapper extends React.Component ))} - + - {this.state.ngInjector && this.renderRoutes()} + {ready && this.renderRoutes()} {bodyRenderHooks.map((Hook, index) => ( ))} diff --git a/public/app/angular/AngularApp.ts b/public/app/angular/AngularApp.ts index a746e355e1b..29977589ea9 100644 --- a/public/app/angular/AngularApp.ts +++ b/public/app/angular/AngularApp.ts @@ -14,6 +14,11 @@ import { extend } from 'lodash'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTemplateSrv } from '@grafana/runtime'; import { registerComponents } from './registerComponents'; +import { exposeToPlugin } from 'app/features/plugins/plugin_loader'; +import appEvents from 'app/core/app_events'; +import { contextSrv } from 'app/core/services/context_srv'; +import * as sdk from 'app/plugins/sdk'; +import { promiseToDigest } from './promiseToDigest'; export class AngularApp { ngModuleDependencies: any[]; @@ -93,6 +98,18 @@ export class AngularApp { registerComponents(); initAngularRoutingBridge(); + // Angular plugins import this + exposeToPlugin('angular', angular); + exposeToPlugin('app/core/utils/promiseToDigest', { promiseToDigest, __esModule: true }); + exposeToPlugin('app/plugins/sdk', sdk); + exposeToPlugin('app/core/core_module', coreModule); + exposeToPlugin('app/core/core', { + coreModule: coreModule, + appEvents: appEvents, + contextSrv: contextSrv, + __esModule: true, + }); + // disable tool tip animation $.fn.tooltip.defaults.animation = false; } diff --git a/public/app/angular/index.ts b/public/app/angular/index.ts index 2dcaee03cab..ada6fe8f6a5 100644 --- a/public/app/angular/index.ts +++ b/public/app/angular/index.ts @@ -9,6 +9,7 @@ import './services/popover_srv'; import './services/timer'; import './services/AngularLoader'; +import '../angular/jquery_extended'; import './dropdown_typeahead'; import './autofill_event_fix'; import './metric_segment'; @@ -37,3 +38,4 @@ import './components/plugin_component'; import './GrafanaCtrl'; export { AngularApp } from './AngularApp'; +export { coreModule } from './core_module'; diff --git a/public/app/core/jquery_extended.ts b/public/app/angular/jquery_extended.ts similarity index 100% rename from public/app/core/jquery_extended.ts rename to public/app/angular/jquery_extended.ts diff --git a/public/app/angular/loadAndInitAngularIfEnabled.ts b/public/app/angular/loadAndInitAngularIfEnabled.ts new file mode 100644 index 00000000000..211f44ae3b8 --- /dev/null +++ b/public/app/angular/loadAndInitAngularIfEnabled.ts @@ -0,0 +1,22 @@ +import { config, setAngularLoader } from '@grafana/runtime'; + +export async function loadAndInitAngularIfEnabled() { + if (config.angularSupportEnabled) { + const { AngularApp } = await import(/* webpackChunkName: "AngularApp" */ './index'); + const app = new AngularApp(); + app.init(); + app.bootstrap(); + } else { + setAngularLoader({ + load: (elem, scopeProps, template) => { + return { + destroy: () => {}, + digest: () => {}, + getScope: () => { + return {}; + }, + }; + }, + }); + } +} diff --git a/public/app/app.ts b/public/app/app.ts index 7f5bcb17836..1ca8c0435d7 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -41,7 +41,7 @@ import 'app/features/all'; import { getScrollbarWidth } from '@grafana/ui'; import { variableAdapters } from './features/variables/adapters'; import { initDevFeatures } from './dev'; -import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { SentryEchoBackend } from './core/services/echo/backends/sentry/SentryBackend'; import { setVariableQueryRunner, VariableQueryRunner } from './features/variables/query/VariableQueryRunner'; import { configureStore } from './store/configureStore'; @@ -62,7 +62,6 @@ 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'; import { initWindowRuntime } from './features/runtime/init'; import { createQueryVariableAdapter } from './features/variables/query/adapter'; @@ -89,12 +88,6 @@ if (process.env.NODE_ENV === 'development') { } export class GrafanaApp { - angularApp: AngularApp; - - constructor() { - this.angularApp = new AngularApp(); - } - async init() { try { setBackendSrv(backendSrv); @@ -148,9 +141,6 @@ export class GrafanaApp { const modalManager = new ModalManager(); modalManager.init(); - // Init angular - this.angularApp.init(); - // Preload selected app plugins await preloadPlugins(config.pluginsToPreload); diff --git a/public/app/core/components/Login/LoginPage.test.tsx b/public/app/core/components/Login/LoginPage.test.tsx index 256dcf4c6b9..3d5161182df 100644 --- a/public/app/core/components/Login/LoginPage.test.tsx +++ b/public/app/core/components/Login/LoginPage.test.tsx @@ -3,8 +3,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { LoginPage } from './LoginPage'; +import * as runtimeMock from '@grafana/runtime'; + const postMock = jest.fn(); jest.mock('@grafana/runtime', () => ({ + __esModule: true, getBackendSrv: () => ({ post: postMock, }), @@ -26,6 +29,10 @@ jest.mock('@grafana/runtime', () => ({ })); describe('Login Page', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('renders correctly', () => { render(); @@ -77,4 +84,16 @@ describe('Login Page', () => { await waitFor(() => expect(postMock).toHaveBeenCalledWith('/login', { password: 'test', user: 'admin' })); expect(window.location.assign).toHaveBeenCalledWith('/'); }); + it('renders social logins correctly', () => { + (runtimeMock as any).config.oauth = { + okta: { + name: 'Okta Test', + icon: 'signin', + }, + }; + + render(); + + expect(screen.getByRole('link', { name: 'Sign in with Okta Test' })).toBeInTheDocument(); + }); }); diff --git a/public/app/core/components/Login/LoginServiceButtons.tsx b/public/app/core/components/Login/LoginServiceButtons.tsx index e51a8b5b972..ab8a648aad7 100644 --- a/public/app/core/components/Login/LoginServiceButtons.tsx +++ b/public/app/core/components/Login/LoginServiceButtons.tsx @@ -55,20 +55,20 @@ const loginServices: () => LoginServices = () => { bgColor: '#262628', enabled: oauthEnabled && Boolean(config.oauth.grafana_com), name: 'Grafana.com', - hrefName: 'grafana_com', icon: 'grafana', + hrefName: 'grafana_com', }, okta: { bgColor: '#2f2f2f', enabled: oauthEnabled && Boolean(config.oauth.okta), - name: 'Okta', - icon: 'okta', + name: config.oauth?.okta?.name || 'Okta', + icon: (config.oauth?.okta?.icon as IconName) || 'okta', }, oauth: { bgColor: '#262628', enabled: oauthEnabled && Boolean(config.oauth.generic_oauth), - name: oauthEnabled && config.oauth.generic_oauth ? config.oauth.generic_oauth.name : 'OAuth', - icon: 'signin', + name: config.oauth?.generic_oauth?.name || 'OAuth', + icon: (config.oauth?.generic_oauth?.icon as IconName) || 'signin', hrefName: 'generic_oauth', }, }; diff --git a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx index 7d85be8a65b..90df0a07fb1 100644 --- a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx +++ b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import { css, cx } from '@emotion/css'; import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; @@ -13,6 +13,7 @@ import { FocusScope } from '@react-aria/focus'; import { NavBarItemMenuContext } from './context'; import { NavFeatureHighlight } from './NavFeatureHighlight'; +import { reportExperimentView } from '@grafana/runtime'; export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { children: ReactElement; @@ -34,6 +35,12 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE const ref = React.useRef(null); const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref); + useEffect(() => { + if (item.highlightId) { + reportExperimentView(`feature-highlights-${item.highlightId}-nav`, 'test', ''); + } + }, [item.highlightId]); + const { hoverProps } = useHover({ onHoverChange: (isHovering) => { if (isHovering) { diff --git a/public/app/core/components/OrgSwitcher.tsx b/public/app/core/components/OrgSwitcher.tsx index 861e455218e..79189a12b56 100644 --- a/public/app/core/components/OrgSwitcher.tsx +++ b/public/app/core/components/OrgSwitcher.tsx @@ -1,91 +1,67 @@ -import React from 'react'; - -import { getBackendSrv } from '@grafana/runtime'; +import React, { ReactElement } from 'react'; +import { css } from '@emotion/css'; import { UserOrgDTO } from '@grafana/data'; -import { Modal, Button, CustomScrollbar } from '@grafana/ui'; +import { Button, CustomScrollbar, Modal } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import config from 'app/core/config'; -import { css } from '@emotion/css'; +import { api } from '../../features/profile/api'; +import { useAsync } from 'react-use'; interface Props { onDismiss: () => void; } -interface State { - orgs: UserOrgDTO[]; -} - -export class OrgSwitcher extends React.PureComponent { - state: State = { - orgs: [], +export function OrgSwitcher({ onDismiss }: Props): ReactElement { + const { value: orgs = [] } = useAsync(() => { + return api.loadOrgs(); + }, []); + const currentOrgId = contextSrv.user.orgId; + const contentClassName = css({ + display: 'flex', + maxHeight: 'calc(85vh - 42px)', + }); + const setCurrentOrg = async (org: UserOrgDTO) => { + await api.setUserOrg(org); + window.location.href = `${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`; }; - componentDidMount() { - this.getUserOrgs(); - } - - getUserOrgs = async () => { - const orgs: UserOrgDTO[] = await getBackendSrv().get('/api/user/orgs'); - this.setState({ orgs }); - }; - - setCurrentOrg = async (org: UserOrgDTO) => { - await getBackendSrv().post(`/api/user/using/${org.orgId}`); - this.setWindowLocation(`${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`); - }; - - setWindowLocation(href: string) { - window.location.href = href; - } - - render() { - const { onDismiss } = this.props; - const { orgs } = this.state; - - const currentOrgId = contextSrv.user.orgId; - const contentClassName = css({ - display: 'flex', - maxHeight: 'calc(85vh - 42px)', - }); - - return ( - - - - - - - -
NameRole + return ( + + + + + + + + + + + {orgs.map((org) => ( + + + + - - - {orgs.map((org) => ( - - - - - - ))} - -
NameRole +
{org.name}{org.role} + {org.orgId === currentOrgId ? ( + + ) : ( + + )} +
{org.name}{org.role} - {org.orgId === currentOrgId ? ( - - ) : ( - - )} -
-
-
- ); - } + ))} + +
+
+
+ ); } diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx index 146b21f9e68..4004424e07f 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx @@ -4,6 +4,7 @@ import { GrafanaTheme } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { useUpdateEffect } from 'react-use'; import { Draggable } from 'react-beautiful-dnd'; +import { reportInteraction } from '@grafana/runtime'; interface QueryOperationRowProps { index: number; @@ -47,6 +48,24 @@ export const QueryOperationRow: React.FC = ({ setIsContentVisible(!isContentVisible); }, [isContentVisible, setIsContentVisible]); + const reportDragMousePosition = useCallback((e) => { + // When drag detected react-beautiful-dnd will preventDefault the event + // Ref: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/how-we-use-dom-events.md#a-mouse-drag-has-started-and-the-user-is-now-dragging + if (e.defaultPrevented) { + const rect = e.currentTarget.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + + // report relative mouse position within the header element + reportInteraction('query_row_reorder_drag_position', { + x: x / rect.width, + y: y / rect.height, + width: rect.width, + height: rect.height, + }); + } + }, []); + useUpdateEffect(() => { if (isContentVisible) { if (onOpen) { @@ -106,7 +125,9 @@ export const QueryOperationRow: React.FC = ({ return ( <>
-
{rowHeader}
+
+ {rowHeader} +
{isContentVisible &&
{children}
}
diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 04cf6dbbbd8..38e3808619d 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -1,5 +1,3 @@ -import './jquery_extended'; -import './services/search_srv'; import { colors, JsonExplorer } from '@grafana/ui/'; import appEvents from './app_events'; import { assignModelProperties } from './utils/model_utils'; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 88ff7d0fdfb..fe0d22f773c 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -10,14 +10,15 @@ import exploreReducers from 'app/features/explore/state/main'; import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; +import invitesReducers from 'app/features/invites/state/reducers'; import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; -import templatingReducers from 'app/features/variables/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers'; import panelsReducers from 'app/features/panel/state/reducers'; import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers'; +import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; const rootReducers = { ...sharedReducers, @@ -31,12 +32,13 @@ const rootReducers = { ...usersReducers, ...serviceAccountsReducer, ...userReducers, + ...invitesReducers, ...organizationReducers, ...ldapReducers, - ...templatingReducers, ...importDashboardReducers, ...panelEditorReducers, ...panelsReducers, + ...templatingReducers, plugins: pluginsReducer, }; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index a232a133e4f..765b2a09b61 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -11,7 +11,7 @@ import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk'; import { RemovePanelEvent, ShiftTimeEvent, - ShiftTimeEventPayload, + ShiftTimeEventDirection, ShowModalReactEvent, ZoomOutEvent, AbsoluteTimeEvent, @@ -171,6 +171,24 @@ export class KeybindingSrv { this.bind(keyArg, withFocusedPanel(fn)); } + setupTimeRangeBindings(updateUrl = true) { + this.bind('t z', () => { + appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); + }); + + this.bind('ctrl+z', () => { + appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); + }); + + this.bind('t left', () => { + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left, updateUrl })); + }); + + this.bind('t right', () => { + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl })); + }); + } + setupDashboardBindings(dashboard: DashboardModel) { this.bind('mod+o', () => { dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; @@ -191,21 +209,7 @@ export class KeybindingSrv { } }); - this.bind('t z', () => { - appEvents.publish(new ZoomOutEvent(2)); - }); - - this.bind('ctrl+z', () => { - appEvents.publish(new ZoomOutEvent(2)); - }); - - this.bind('t left', () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left)); - }); - - this.bind('t right', () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right)); - }); + this.setupTimeRangeBindings(); // edit panel this.bindWithPanelId('e', (panelId) => { diff --git a/public/app/core/specs/OrgSwitcher.test.tsx b/public/app/core/specs/OrgSwitcher.test.tsx index 9ab35afa612..829fa3ac9c0 100644 --- a/public/app/core/specs/OrgSwitcher.test.tsx +++ b/public/app/core/specs/OrgSwitcher.test.tsx @@ -1,15 +1,12 @@ import React from 'react'; -import { OrgSwitcher } from '../components/OrgSwitcher'; -import { shallow } from 'enzyme'; -import { OrgRole } from '@grafana/data'; +import { render, screen, waitFor, within } from '@testing-library/react'; -const postMock = jest.fn().mockImplementation(jest.fn()); +import { OrgSwitcher } from '../components/OrgSwitcher'; +import { api } from '../../features/profile/api'; +import userEvent from '@testing-library/user-event'; +import { OrgRole } from '../../types'; jest.mock('@grafana/runtime', () => ({ - getBackendSrv: () => ({ - get: jest.fn().mockResolvedValue([]), - post: postMock, - }), config: { appSubUrl: '/subUrl', }, @@ -21,25 +18,54 @@ jest.mock('app/core/services/context_srv', () => ({ }, })); -let wrapper; -let orgSwitcher: OrgSwitcher; - describe('OrgSwitcher', () => { + const { location } = window; + let setUserOrgSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + const orgs = [ + { orgId: 1, name: 'Main Org.', role: OrgRole.Admin }, + { orgId: 2, name: 'Org 2', role: OrgRole.Admin }, + ]; + const loadOrgsSpy = jest.spyOn(api, 'loadOrgs').mockResolvedValue(orgs); + setUserOrgSpy = jest.spyOn(api, 'setUserOrg').mockResolvedValue(undefined); + + // @ts-ignore + delete window.location; + window.location = {} as Location; + + render( {}} />); + await waitFor(() => expect(loadOrgsSpy).toHaveBeenCalledTimes(1)); + }); + + afterEach(() => { + window.location = location; + }); + describe('when switching org', () => { - beforeEach(async () => { - wrapper = shallow( {}} />); - orgSwitcher = wrapper.instance() as OrgSwitcher; - orgSwitcher.setWindowLocation = jest.fn(); - wrapper.update(); - await orgSwitcher.setCurrentOrg({ name: 'mock org', orgId: 2, role: OrgRole.Viewer }); + it('should render correct rows', async () => { + expect(screen.getAllByRole('row')).toHaveLength(3); // header + 2 orgs + expect(screen.getByRole('row', { name: /main org. admin current/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /org 2 admin switch to/i })).toBeInTheDocument(); }); - it('should switch orgId in call to backend', () => { - expect(postMock).toBeCalledWith('/api/user/using/2'); + it('should switch orgId in call to backend', async () => { + const row = screen.getByRole('row', { name: /org 2 admin switch to/i }); + const switchToButton = within(row).getByText(/switch to/i); + userEvent.click(switchToButton); + + await waitFor(() => expect(setUserOrgSpy).toBeCalledWith({ orgId: 2, name: 'Org 2', role: 'Admin' })); }); - it('should switch orgId in url and redirect to home page', () => { - expect(orgSwitcher.setWindowLocation).toBeCalledWith('/subUrl/?orgId=2'); + it('should redirect to home page', async () => { + expect(window.location.href).toBeUndefined(); + + const row = screen.getByRole('row', { name: /org 2 admin switch to/i }); + const switchToButton = within(row).getByText(/switch to/i); + userEvent.click(switchToButton); + + await waitFor(() => expect(window.location.href).toEqual('/subUrl/?orgId=2')); }); }); }); diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index 284a5848309..fb77a6d1a7d 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -105,7 +105,12 @@ export async function parseResponseBody( return response.blob() as any; case 'json': - return response.json(); + try { + return await response.json(); + } catch (err) { + console.warn(`${response.url} returned an invalid JSON -`, err); + return {} as unknown as T; + } case 'text': return response.text() as any; diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts deleted file mode 100644 index a8815ae8adf..00000000000 --- a/public/app/core/utils/standardTransformers.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { TransformerRegistryItem } from '@grafana/data'; -import { reduceTransformRegistryItem } from '../components/TransformersUI/ReduceTransformerEditor'; -import { filterFieldsByNameTransformRegistryItem } from '../components/TransformersUI/FilterByNameTransformerEditor'; -import { filterFramesByRefIdTransformRegistryItem } from '../components/TransformersUI/FilterByRefIdTransformerEditor'; -import { filterByValueTransformRegistryItem } from '../components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor'; -import { organizeFieldsTransformRegistryItem } from '../components/TransformersUI/OrganizeFieldsTransformerEditor'; -import { seriesToFieldsTransformerRegistryItem } from '../components/TransformersUI/SeriesToFieldsTransformerEditor'; -import { calculateFieldTransformRegistryItem } from '../components/TransformersUI/CalculateFieldTransformerEditor'; -import { labelsToFieldsTransformerRegistryItem } from '../components/TransformersUI/LabelsToFieldsTransformerEditor'; -import { groupByTransformRegistryItem } from '../components/TransformersUI/GroupByTransformerEditor'; -import { sortByTransformRegistryItem } from '../components/TransformersUI/SortByTransformerEditor'; -import { mergeTransformerRegistryItem } from '../components/TransformersUI/MergeTransformerEditor'; -import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor'; -import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor'; -import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer'; -import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor'; -import { rowsToFieldsTransformRegistryItem } from '../components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor'; -import { configFromQueryTransformRegistryItem } from '../components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor'; -import { prepareTimeseriesTransformerRegistryItem } from '../components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor'; -import { convertFieldTypeTransformRegistryItem } from '../components/TransformersUI/ConvertFieldTypeTransformerEditor'; -import { fieldLookupTransformRegistryItem } from '../components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor'; -import { extractFieldsTransformRegistryItem } from '../components/TransformersUI/extractFields/ExtractFieldsTransformerEditor'; -import { spatialTransformRegistryItem } from '../components/TransformersUI/spatial/SpatialTransformerEditor'; - -export const getStandardTransformers = (): Array> => { - return [ - reduceTransformRegistryItem, - filterFieldsByNameTransformRegistryItem, - renameByRegexTransformRegistryItem, - filterFramesByRefIdTransformRegistryItem, - filterByValueTransformRegistryItem, - organizeFieldsTransformRegistryItem, - seriesToFieldsTransformerRegistryItem, - seriesToRowsTransformerRegistryItem, - concatenateTransformRegistryItem, - calculateFieldTransformRegistryItem, - labelsToFieldsTransformerRegistryItem, - groupByTransformRegistryItem, - sortByTransformRegistryItem, - mergeTransformerRegistryItem, - histogramTransformRegistryItem, - rowsToFieldsTransformRegistryItem, - configFromQueryTransformRegistryItem, - prepareTimeseriesTransformerRegistryItem, - convertFieldTypeTransformRegistryItem, - spatialTransformRegistryItem, - fieldLookupTransformRegistryItem, - extractFieldsTransformRegistryItem, - ]; -}; diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 675b0ea38f9..c883750e161 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -94,7 +94,7 @@ export const ServerStats = () => {

No stats found.

)} - {config.featureToggles.dashboardPreviews && } + {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && } ); }; diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx index 3348293a19e..2c75d68fc49 100644 --- a/public/app/features/alerting/AlertTab.tsx +++ b/public/app/features/alerting/AlertTab.tsx @@ -2,13 +2,10 @@ import React, { PureComponent } from 'react'; import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; import { Alert, Button, ConfirmModal, Container, CustomScrollbar, HorizontalGroup, IconName, Modal } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; -import { AngularComponent, getAngularLoader, getDataSourceSrv } from '@grafana/runtime'; +import { AngularComponent, config, getAngularLoader, getDataSourceSrv } from '@grafana/runtime'; import { getAlertingValidationMessage } from './getAlertingValidationMessage'; - import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import StateHistory from './StateHistory'; -import 'app/features/alerting/AlertTabCtrl'; - import { DashboardModel } from '../dashboard/state/DashboardModel'; import { PanelModel } from '../dashboard/state/PanelModel'; import { TestRuleResult } from './TestRuleResult'; @@ -57,8 +54,14 @@ class UnConnectedAlertTab extends PureComponent { showTestRule: false, }; - componentDidMount() { - this.loadAlertTab(); + async componentDidMount() { + if (config.angularSupportEnabled) { + await import(/* webpackChunkName: "AlertTabCtrl" */ 'app/features/alerting/AlertTabCtrl'); + this.loadAlertTab(); + } else { + // TODO probably need to migrate AlertTab to react + alert('Angular support disabled, legacy alerting cannot function without angular support'); + } } onAngularPanelUpdated = () => { diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 25cd0320f56..9986590701c 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -13,6 +13,7 @@ import { TestReceiversAlert, TestReceiversPayload, TestReceiversResult, + ExternalAlertmanagerConfig, } from 'app/plugins/datasource/alertmanager/types'; import { lastValueFrom } from 'rxjs'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; @@ -222,11 +223,11 @@ function getReceiverResultError(receiversResult: TestReceiversResult) { .join('; '); } -export async function addAlertManagers(alertManagers: string[]): Promise { +export async function addAlertManagers(alertManagerConfig: ExternalAlertmanagerConfig): Promise { await lastValueFrom( getBackendSrv().fetch({ method: 'POST', - data: { alertmanagers: alertManagers }, + data: alertManagerConfig, url: '/api/v1/ngalert/admin_config', showErrorAlert: false, showSuccessAlert: false, @@ -247,9 +248,9 @@ export async function fetchExternalAlertmanagers(): Promise { +export async function fetchExternalAlertmanagerConfig(): Promise { const result = await lastValueFrom( - getBackendSrv().fetch<{ alertmanagers: string[] }>({ + getBackendSrv().fetch({ method: 'GET', url: '/api/v1/ngalert/admin_config', showErrorAlert: false, diff --git a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx index 82b258803bb..aabb017f588 100644 --- a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx +++ b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx @@ -1,19 +1,17 @@ import React, { FC, useMemo } from 'react'; import { css, cx } from '@emotion/css'; -import { useDispatch } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, Field, FieldArray, Form, Icon, Input, Modal, useStyles2 } from '@grafana/ui'; -import { addExternalAlertmanagersAction } from '../../state/actions'; import { AlertmanagerUrl } from 'app/plugins/datasource/alertmanager/types'; interface Props { onClose: () => void; alertmanagers: AlertmanagerUrl[]; + onChangeAlertmanagerConfig: (alertmanagers: string[]) => void; } -export const AddAlertManagerModal: FC = ({ alertmanagers, onClose }) => { +export const AddAlertManagerModal: FC = ({ alertmanagers, onChangeAlertmanagerConfig, onClose }) => { const styles = useStyles2(getStyles); - const dispatch = useDispatch(); const defaultValues: Record = useMemo( () => ({ alertmanagers: alertmanagers, @@ -29,7 +27,7 @@ export const AddAlertManagerModal: FC = ({ alertmanagers, onClose }) => { ); const onSubmit = (values: Record) => { - dispatch(addExternalAlertmanagersAction(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url)))); + onChangeAlertmanagerConfig(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url))); onClose(); }; diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx index 9bb29cac46d..efcbccf9ab6 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx @@ -1,8 +1,18 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { css } from '@emotion/css'; +import { useDispatch, useSelector } from 'react-redux'; +import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ConfirmModal, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { + Button, + ConfirmModal, + Field, + HorizontalGroup, + Icon, + RadioButtonGroup, + Tooltip, + useStyles2, + useTheme2, +} from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { AddAlertManagerModal } from './AddAlertManagerModal'; import { @@ -11,13 +21,25 @@ import { fetchExternalAlertmanagersConfigAction, } from '../../state/actions'; import { useExternalAmSelector } from '../../hooks/useExternalAmSelector'; +import { StoreState } from 'app/types/store'; + +const alertmanagerChoices = [ + { value: 'internal', label: 'Only Internal' }, + { value: 'external', label: 'Only External' }, + { value: 'all', label: 'Both internal and external' }, +]; export const ExternalAlertmanagers = () => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] }); const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 }); + const externalAlertManagers = useExternalAmSelector(); + const alertmanagersChoice = useSelector( + (state: StoreState) => state.unifiedAlerting.externalAlertmanagers.alertmanagerConfig.result?.alertmanagersChoice + ); + const theme = useTheme2(); useEffect(() => { dispatch(fetchExternalAlertmanagersAction()); @@ -37,10 +59,12 @@ export const ExternalAlertmanagers = () => { .map((am) => { return am.url; }); - dispatch(addExternalAlertmanagersAction(newList)); + dispatch( + addExternalAlertmanagersAction({ alertmanagers: newList, alertmanagersChoice: alertmanagersChoice ?? 'all' }) + ); setDeleteModalState({ open: false, index: 0 }); }, - [externalAlertManagers, dispatch] + [externalAlertManagers, dispatch, alertmanagersChoice] ); const onEdit = useCallback(() => { @@ -70,16 +94,26 @@ export const ExternalAlertmanagers = () => { })); }, [setModalState]); + const onChangeAlertmanagerChoice = (alertmanagersChoice: string) => { + dispatch( + addExternalAlertmanagersAction({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice }) + ); + }; + + const onChangeAlertmanagers = (alertmanagers: string[]) => { + dispatch(addExternalAlertmanagersAction({ alertmanagers, alertmanagersChoice: alertmanagersChoice ?? 'all' })); + }; + const getStatusColor = (status: string) => { switch (status) { case 'active': - return 'green'; + return theme.colors.success.main; case 'pending': - return 'yellow'; + return theme.colors.warning.main; default: - return 'red'; + return theme.colors.error.main; } }; @@ -107,49 +141,63 @@ export const ExternalAlertmanagers = () => { buttonIcon="bell-slash" /> ) : ( - - - - - - - - - - {externalAlertManagers?.map((am, index) => { - return ( - - - - - - ); - })} - -
UrlStatusAction
- {am.url} - {am.actualUrl ? ( - - - - ) : null} - - - - - - - -
+ <> + + + + + + + + + + {externalAlertManagers?.map((am, index) => { + return ( + + + + + + ); + })} + +
UrlStatusAction
+ {am.url} + {am.actualUrl ? ( + + + + ) : null} + + + + + + + +
+
+ + onChangeAlertmanagerChoice(value!)} + /> + +
+ )} { onConfirm={() => onDelete(deleteModalState.index)} onDismiss={() => setDeleteModalState({ open: false, index: 0 })} /> - {modalState.open && } + {modalState.open && ( + + )}
); }; @@ -176,5 +230,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: flex; justify-content: flex-end; `, - table: css``, + table: css` + margin-bottom: ${theme.spacing(2)}; + `, }); diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts index af54659548d..a7fcee29adf 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts @@ -1,7 +1,7 @@ import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../../types/amroutes'; import { MatcherFieldValue } from '../../types/silence-form'; -import { getFilteredRoutes } from './AmRoutesTable'; +import { deleteRoute, getFilteredRoutes, updatedRoute } from './AmRoutesTable'; const defaultAmRoute: FormAmRoute = { id: '', @@ -110,3 +110,79 @@ describe('getFilteredRoutes', () => { expect(filteredRoutes).toContain(routes[2]); }); }); + +describe('updatedRoute', () => { + it('Should update an item of the same id', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + const routeUpdate: FormAmRoute = { + ...routes[1], + object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)], + }; + + // Act + const updatedRoutes = updatedRoute(routes, routeUpdate); + + // Assert + expect(updatedRoutes).toHaveLength(3); + const changedRoute = updatedRoutes[1]; + + expect(changedRoute.object_matchers).toHaveLength(1); + expect(changedRoute.object_matchers[0].name).toBe('severity'); + expect(changedRoute.object_matchers[0].value).toBe('critical'); + expect(changedRoute.object_matchers[0].operator).toBe(MatcherOperator.equal); + }); + + it('Should not update any element when an element of matching id not found', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + const routeUpdate: FormAmRoute = { + ...routes[1], + id: '-1', + object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)], + }; + + // Act + const updatedRoutes = updatedRoute(routes, routeUpdate); + + // Assert + expect(updatedRoutes).toHaveLength(3); + + updatedRoutes.forEach((route) => { + expect(route.object_matchers).toHaveLength(0); + }); + }); +}); + +describe('deleteRoute', () => { + it('Should delete an element of the same id', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + const routeToDelete = routes[1]; + + // Act + const updatedRoutes = deleteRoute(routes, routeToDelete); + + // Assert + expect(updatedRoutes).toHaveLength(2); + expect(updatedRoutes[0].id).toBe('1'); + expect(updatedRoutes[1].id).toBe('3'); + }); + + it('Should not delete anything when an element of matching id not found', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + // Act + const updatedRoutes = deleteRoute(routes, buildAmRoute({ id: '-1' })); + + // Assert + expect(updatedRoutes).toHaveLength(3); + expect(updatedRoutes[0].id).toBe('1'); + expect(updatedRoutes[1].id).toBe('2'); + expect(updatedRoutes[2].id).toBe('3'); + }); +}); diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx index b76807394e8..e5cb1df5d46 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx @@ -44,6 +44,23 @@ export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: str return filteredRoutes; }; +export const updatedRoute = (routes: FormAmRoute[], updatedRoute: FormAmRoute): FormAmRoute[] => { + const newRoutes = [...routes]; + const editIndex = newRoutes.findIndex((route) => route.id === updatedRoute.id); + + if (editIndex >= 0) { + newRoutes[editIndex] = { + ...newRoutes[editIndex], + ...updatedRoute, + }; + } + return newRoutes; +}; + +export const deleteRoute = (routes: FormAmRoute[], routeToRemove: FormAmRoute): FormAmRoute[] => { + return routes.filter((route) => route.id !== routeToRemove.id); +}; + export const AmRoutesTable: FC = ({ isAddMode, onCancelAdd, @@ -92,7 +109,7 @@ export const AmRoutesTable: FC = ({ id: 'actions', label: 'Actions', // eslint-disable-next-line react/display-name - renderCell: (item, index) => { + renderCell: (item) => { if (item.renderExpandedContent) { return null; } @@ -118,10 +135,7 @@ export const AmRoutesTable: FC = ({ aria-label="Delete route" name="trash-alt" onClick={() => { - const newRoutes = [...routes]; - - newRoutes.splice(index, 1); - + const newRoutes = deleteRoute(routes, item.data); onChange(newRoutes); }} type="button" @@ -144,11 +158,14 @@ export const AmRoutesTable: FC = ({ [isAddMode, routes, filteredRoutes] ); - // expand the last item when adding + // expand the last item when adding or reset when the length changed useEffect(() => { if (isAddMode && dynamicTableRoutes.length) { setExpandedId(dynamicTableRoutes[dynamicTableRoutes.length - 1].id); } + if (!isAddMode && dynamicTableRoutes.length) { + setExpandedId(undefined); + } }, [isAddMode, dynamicTableRoutes]); if (routes.length > 0 && filteredRoutes.length === 0) { @@ -168,7 +185,7 @@ export const AmRoutesTable: FC = ({ onCollapse={collapseItem} onExpand={expandItem} isExpanded={(item) => expandedId === item.id} - renderExpandedContent={(item: RouteTableItemProps, index) => + renderExpandedContent={(item: RouteTableItemProps) => isAddMode || editMode ? ( { @@ -178,12 +195,8 @@ export const AmRoutesTable: FC = ({ setEditMode(false); }} onSave={(data) => { - const newRoutes = [...routes]; + const newRoutes = updatedRoute(routes, data); - newRoutes[index] = { - ...newRoutes[index], - ...data, - }; setEditMode(false); onChange(newRoutes); }} @@ -193,13 +206,7 @@ export const AmRoutesTable: FC = ({ ) : ( { - const newRoutes = [...routes]; - - newRoutes[index] = { - ...item.data, - ...data, - }; - + const newRoutes = updatedRoute(routes, data); onChange(newRoutes); }} receivers={receivers} diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index 516986bb8ee..ccc021b313b 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -5,7 +5,7 @@ import { useCleanup } from 'app/core/hooks/useCleanup'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { NotifierDTO } from 'app/types'; import React, { useCallback } from 'react'; -import { useForm, FormProvider, FieldErrors, Validate } from 'react-hook-form'; +import { useForm, FormProvider, FieldErrors, Validate, SubmitHandler } from 'react-hook-form'; import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form'; @@ -94,7 +94,7 @@ export function ReceiverForm({ Because there is no default policy configured yet, this contact point will automatically be set as default. )} -
+ >, onInvalid)}>

{readOnly ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'}

@@ -121,13 +121,13 @@ export function ReceiverForm({ defaultValues={field} key={field.__id} onDuplicate={() => { - const currentValues: R = getValues().items[index]; + const currentValues = getValues().items[index] as R; append({ ...currentValues, __id: String(Math.random()) }); }} onTest={ onTestChannel ? () => { - const currentValues: R = getValues().items[index]; + const currentValues = getValues().items[index] as R; onTestChannel(currentValues); } : undefined diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx index 4a96553d037..54daf57fa7c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -10,6 +10,7 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { contextSrv } from 'app/core/services/context_srv'; import { CloudRulesSourcePicker } from './CloudRulesSourcePicker'; import { checkForPathSeparator } from './util'; +import { get } from 'lodash'; interface Props { editingExistingRule: boolean; @@ -148,8 +149,9 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result?.data ); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index ead22ca396c..38e4a4fdb40 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -2,6 +2,7 @@ import { getBackendSrv, locationService } from '@grafana/runtime'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { AlertmanagerAlert, + ExternalAlertmanagerConfig, AlertManagerCortexConfig, AlertmanagerGroup, ExternalAlertmanagersResponse, @@ -122,7 +123,7 @@ export const fetchExternalAlertmanagersAction = createAsyncThunk( export const fetchExternalAlertmanagersConfigAction = createAsyncThunk( 'unifiedAlerting/fetchExternAlertmanagersConfig', - (): Promise<{ alertmanagers: string[] }> => { + (): Promise => { return withSerializedError(fetchExternalAlertmanagerConfig()); } ); @@ -808,11 +809,11 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( export const addExternalAlertmanagersAction = createAsyncThunk( 'unifiedAlerting/addExternalAlertmanagers', - async (alertManagerUrls: string[], thunkAPI): Promise => { + async (alertmanagerConfig: ExternalAlertmanagerConfig, thunkAPI): Promise => { return withAppEvents( withSerializedError( (async () => { - await addAlertManagers(alertManagerUrls); + await addAlertManagers(alertmanagerConfig); thunkAPI.dispatch(fetchExternalAlertmanagersConfigAction()); })() ), diff --git a/public/app/features/alerting/unified/utils/receiver-form.ts b/public/app/features/alerting/unified/utils/receiver-form.ts index 801e5bfc997..f4aa3886fa6 100644 --- a/public/app/features/alerting/unified/utils/receiver-form.ts +++ b/public/app/features/alerting/unified/utils/receiver-form.ts @@ -1,4 +1,3 @@ -import { isArray } from 'angular'; import { AlertManagerCortexConfig, GrafanaManagedReceiverConfig, @@ -6,6 +5,7 @@ import { Route, } from 'app/plugins/datasource/alertmanager/types'; import { CloudNotifierType, NotifierDTO, NotifierType } from 'app/types'; +import { isArray } from 'lodash'; import { CloudChannelConfig, CloudChannelMap, diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 94313c0ec21..640efa92c3a 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -13,7 +13,7 @@ import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePicker // Utils & Services import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { appEvents } from 'app/core/core'; -import { ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../../types/events'; +import { ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../../types/events'; import { Unsubscribable } from 'rxjs'; export interface Props { @@ -38,16 +38,16 @@ export class DashNavTimeControls extends Component { }; onRefresh = () => { - getTimeSrv().refreshDashboard(); + getTimeSrv().refreshTimeModel(); return Promise.resolve(); }; onMoveBack = () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left)); + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left })); }; onMoveForward = () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right)); + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right })); }; onChangeTimePicker = (timeRange: TimeRange) => { @@ -77,7 +77,7 @@ export class DashNavTimeControls extends Component { }; onZoom = () => { - appEvents.publish(new ZoomOutEvent(2)); + appEvents.publish(new ZoomOutEvent({ scale: 2 })); }; render() { diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx index 24095f3320d..1a273bab2e4 100644 --- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx +++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx @@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react'; import { Prompt } from 'react-router-dom'; import { DashboardModel } from '../../state/DashboardModel'; import { each, filter, find } from 'lodash'; -import angular from 'angular'; import { UnsavedChangesModal } from '../SaveDashboard/UnsavedChangesModal'; import * as H from 'history'; import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal'; @@ -248,8 +247,8 @@ export function hasChanges(current: DashboardModel, original: any) { currentTimepicker.now = originalTimepicker.now; } - const currentJson = angular.toJson(currentClean); - const originalJson = angular.toJson(originalClean); + const currentJson = JSON.stringify(currentClean, null); + const originalJson = JSON.stringify(originalClean, null); return currentJson !== originalJson; } diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 125beaab782..486cacbe7c6 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -70,7 +70,7 @@ export function DashboardSettings({ dashboard, editview }: Props) { title: 'Variables', id: 'templating', icon: 'calculator-alt', - component: , + component: , }); pages.push({ diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 5ad19559403..5a509359a14 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -122,7 +122,9 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe - {config.featureToggles.dashboardPreviews && } + {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && ( + + )} { +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { const panel = state.panelEditor.getPanel(); const panelState = getPanelStateForModel(state, panel); @@ -73,7 +73,7 @@ const mapStateToProps = (state: StoreState) => { initDone: state.panelEditor.initDone, uiState: state.panelEditor.ui, tableViewEnabled: state.panelEditor.tableViewEnabled, - variables: getVariables(state), + variables: getVariablesByKey(ownProps.dashboard.uid, state), }; }; diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index 9dda02baca4..0ad229d26df 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { Select } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; -import { getVariables } from '../../../variables/state/selectors'; +import { getLastKey, getVariablesByKey } from '../../../variables/state/selectors'; import { StoreState } from '../../../../types'; export interface Props { @@ -13,7 +13,9 @@ export interface Props { } export const RepeatRowSelect: FC = ({ repeat, onChange, id }) => { - const variables = useSelector((state: StoreState) => getVariables(state)); + const variables = useSelector((state: StoreState) => { + return getVariablesByKey(getLastKey(state), state); + }); const variableOptions = useMemo(() => { const options = variables.map((item: any) => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx index 3ef55c9c1b1..b93827ad01a 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -96,7 +96,7 @@ function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContex } describe('ShareModal', () => { - let templateSrv = initTemplateSrv([]); + let templateSrv = initTemplateSrv('key', []); beforeAll(() => { variableAdapters.register(createQueryVariableAdapter()); diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index 01aeca07e61..14839796da0 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { connect, MapStateToProps } from 'react-redux'; import { StoreState } from '../../../../types'; -import { getSubMenuVariables } from '../../../variables/state/selectors'; +import { getSubMenuVariables, getVariablesState } from '../../../variables/state/selectors'; import { VariableModel } from '../../../variables/types'; import { DashboardModel } from '../../state'; import { DashboardLinks } from './DashboardLinks'; @@ -64,9 +64,11 @@ class SubMenuUnConnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state) => { +const mapStateToProps: MapStateToProps = (state, ownProps) => { + const { uid } = ownProps.dashboard; + const templatingState = getVariablesState(uid, state); return { - variables: getSubMenuVariables(state.templating.variables), + variables: getSubMenuVariables(uid, templatingState.variables), }; }; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx index 5269a9a6d37..da6ff1462fd 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx @@ -4,7 +4,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TransformationsEditor } from './TransformationsEditor'; import { PanelModel } from '../../state'; -import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { selectors } from '@grafana/e2e-selectors'; const setup = (transformations: DataTransformerConfig[] = []) => { diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 309adc30170..09ab1695faf 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -168,7 +168,7 @@ export class UnthemedDashboardPage extends PureComponent { const templateVarChanges = findTemplateVarChanges(this.props.queryParams, prevProps.queryParams); if (templateVarChanges) { - templateVarsChangedInUrl(templateVarChanges); + templateVarsChangedInUrl(dashboard.uid, templateVarChanges); } } diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts index 34a03f6dfe0..90220dd63c1 100644 --- a/public/app/features/dashboard/services/TimeSrv.test.ts +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -244,6 +244,11 @@ describe('timeSrv', () => { expect(locationUpdates[1].search).toEqual('?kiosk&from=now-1h&to=now-10s'); }); + + it('should not change the URL if the updateUrl param is false', () => { + timeSrv.setTime({ from: '1644340584281', to: '1644340584281' }, false); + expect(locationUpdates.length).toBe(0); + }); }); describe('pauseAutoRefresh', () => { diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index 4892ac510a9..490f480ce55 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -9,14 +9,14 @@ import { TimeRange, toUtc, } from '@grafana/data'; -import { DashboardModel } from '../state/DashboardModel'; import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { config } from 'app/core/config'; import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; import { locationService } from '@grafana/runtime'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../types/events'; +import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events'; import { contextSrv, ContextSrv } from 'app/core/services/context_srv'; import appEvents from 'app/core/app_events'; +import { TimeModel } from '../state/TimeModel'; export class TimeSrv { time: any; @@ -24,21 +24,21 @@ export class TimeSrv { refresh: any; previousAutoRefresh: any; oldRefresh: string | null | undefined; - dashboard?: DashboardModel; + timeModel?: TimeModel; timeAtLoad: any; private autoRefreshBlocked?: boolean; constructor(private contextSrv: ContextSrv) { // default time this.time = getDefaultTimeRange().raw; - this.refreshDashboard = this.refreshDashboard.bind(this); + this.refreshTimeModel = this.refreshTimeModel.bind(this); appEvents.subscribe(ZoomOutEvent, (e) => { - this.zoomOut(e.payload); + this.zoomOut(e.payload.scale, e.payload.updateUrl); }); appEvents.subscribe(ShiftTimeEvent, (e) => { - this.shiftTime(e.payload); + this.shiftTime(e.payload.direction, e.payload.updateUrl); }); appEvents.subscribe(AbsoluteTimeEvent, () => { @@ -48,15 +48,15 @@ export class TimeSrv { document.addEventListener('visibilitychange', () => { if (this.autoRefreshBlocked && document.visibilityState === 'visible') { this.autoRefreshBlocked = false; - this.refreshDashboard(); + this.refreshTimeModel(); } }); } - init(dashboard: DashboardModel) { - this.dashboard = dashboard; - this.time = dashboard.time; - this.refresh = dashboard.refresh; + init(timeModel: TimeModel) { + this.timeModel = timeModel; + this.time = timeModel.time; + this.refresh = timeModel.refresh; this.initTimeFromUrl(); this.parseTime(); @@ -66,8 +66,8 @@ export class TimeSrv { const range = rangeUtil.convertRawToRange( this.time, - this.dashboard?.getTimezone(), - this.dashboard?.fiscalYearStartMonth + this.timeModel?.getTimezone(), + this.timeModel?.fiscalYearStartMonth ); if (range.to.isBefore(range.from)) { @@ -159,11 +159,11 @@ export class TimeSrv { this.time.to = this.parseUrlParam(params.get('to')!) || this.time.to; } - // if absolute ignore refresh option saved to dashboard + // if absolute ignore refresh option saved to timeModel if (params.get('to') && params.get('to')!.indexOf('now') === -1) { this.refresh = false; - if (this.dashboard) { - this.dashboard.refresh = false; + if (this.timeModel) { + this.timeModel.refresh = false; } } @@ -176,8 +176,8 @@ export class TimeSrv { this.refresh = getRefreshFromUrl({ params: paramsJSON, currentRefresh: this.refresh, - refreshIntervals: Array.isArray(this.dashboard?.timepicker?.refresh_intervals) - ? this.dashboard?.timepicker?.refresh_intervals + refreshIntervals: Array.isArray(this.timeModel?.timepicker?.refresh_intervals) + ? this.timeModel?.timepicker?.refresh_intervals : undefined, isAllowedIntervalFn: this.contextSrv.isAllowedInterval, minRefreshInterval: config.minRefreshInterval, @@ -213,8 +213,8 @@ export class TimeSrv { } setAutoRefresh(interval: any) { - if (this.dashboard) { - this.dashboard.refresh = interval; + if (this.timeModel) { + this.timeModel.refresh = interval; } this.stopAutoRefresh(); @@ -235,7 +235,7 @@ export class TimeSrv { this.refreshTimer = setTimeout(() => { this.startNextRefreshTimer(intervalMs); - this.refreshDashboard(); + this.refreshTimeModel(); }, intervalMs); const refresh = this.contextSrv.getValidInterval(interval); @@ -245,15 +245,15 @@ export class TimeSrv { } } - refreshDashboard() { - this.dashboard?.timeRangeUpdated(this.timeRange()); + refreshTimeModel() { + this.timeModel?.timeRangeUpdated(this.timeRange()); } private startNextRefreshTimer(afterMs: number) { this.refreshTimer = setTimeout(() => { this.startNextRefreshTimer(afterMs); if (this.contextSrv.isGrafanaVisible()) { - this.refreshDashboard(); + this.refreshTimeModel(); } else { this.autoRefreshBlocked = true; } @@ -264,10 +264,10 @@ export class TimeSrv { clearTimeout(this.refreshTimer); } - // store dashboard refresh value and pause auto-refresh in some places + // store timeModel refresh value and pause auto-refresh in some places // i.e panel edit pauseAutoRefresh() { - this.previousAutoRefresh = this.dashboard?.refresh; + this.previousAutoRefresh = this.timeModel?.refresh; this.setAutoRefresh(''); } @@ -276,20 +276,19 @@ export class TimeSrv { this.setAutoRefresh(this.previousAutoRefresh); } - setTime(time: RawTimeRange, fromRouteUpdate?: boolean) { + setTime(time: RawTimeRange, updateUrl = true) { extend(this.time, time); // disable refresh if zoom in or zoom out if (isDateTime(time.to)) { - this.oldRefresh = this.dashboard?.refresh || this.oldRefresh; + this.oldRefresh = this.timeModel?.refresh || this.oldRefresh; this.setAutoRefresh(false); - } else if (this.oldRefresh && this.oldRefresh !== this.dashboard?.refresh) { + } else if (this.oldRefresh && this.oldRefresh !== this.timeModel?.refresh) { this.setAutoRefresh(this.oldRefresh); this.oldRefresh = null; } - // update url - if (fromRouteUpdate !== true) { + if (updateUrl === true) { const urlRange = this.timeRangeForUrl(); const urlParams = locationService.getSearchObject(); @@ -303,7 +302,7 @@ export class TimeSrv { locationService.partial(urlParams); } - this.refreshDashboard(); + this.refreshTimeModel(); } timeRangeForUrl = () => { @@ -326,30 +325,33 @@ export class TimeSrv { to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to, }; - const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined; + const timezone = this.timeModel ? this.timeModel.getTimezone() : undefined; return { - from: dateMath.parse(raw.from, false, timezone, this.dashboard?.fiscalYearStartMonth)!, - to: dateMath.parse(raw.to, true, timezone, this.dashboard?.fiscalYearStartMonth)!, + from: dateMath.parse(raw.from, false, timezone, this.timeModel?.fiscalYearStartMonth)!, + to: dateMath.parse(raw.to, true, timezone, this.timeModel?.fiscalYearStartMonth)!, raw: raw, }; } - zoomOut(factor: number) { + zoomOut(factor: number, updateUrl = true) { const range = this.timeRange(); const { from, to } = getZoomedTimeRange(range, factor); - this.setTime({ from: toUtc(from), to: toUtc(to) }); + this.setTime({ from: toUtc(from), to: toUtc(to) }, updateUrl); } - shiftTime(direction: ShiftTimeEventPayload) { + shiftTime(direction: ShiftTimeEventDirection, updateUrl = true) { const range = this.timeRange(); const { from, to } = getShiftedTimeRange(direction, range); - this.setTime({ - from: toUtc(from), - to: toUtc(to), - }); + this.setTime( + { + from: toUtc(from), + to: toUtc(to), + }, + updateUrl + ); } makeAbsoluteTime() { @@ -359,7 +361,7 @@ export class TimeSrv { } const { from, to } = this.timeRange(); - this.setTime({ from, to }); + this.setTime({ from, to }, true); } // isRefreshOutsideThreshold function calculates the difference between last refresh and now diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 212cb450d40..e3afab19aea 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -20,6 +20,7 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERT import { contextSrv } from 'app/core/services/context_srv'; // Types import { GridPos, PanelModel } from './PanelModel'; +import { TimeModel } from './TimeModel'; import { DashboardMigrator } from './DashboardMigrator'; import { AnnotationQuery, @@ -36,7 +37,7 @@ import { UrlQueryValue, } from '@grafana/data'; import { CoreEvents, DashboardMeta, KioskMode } from 'app/types'; -import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; +import { GetVariables, getVariablesByKey } from 'app/features/variables/state/selectors'; import { variableAdapters } from 'app/features/variables/adapters'; import { onTimeRangeUpdated } from 'app/features/variables/state/actions'; import { dispatch } from '../../../store/store'; @@ -78,7 +79,7 @@ export interface DashboardLink { includeVars: boolean; } -export class DashboardModel { +export class DashboardModel implements TimeModel { id: any; uid: string; title: string; @@ -138,7 +139,7 @@ export class DashboardModel { lastRefresh: true, }; - constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) { + constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariablesByKey) { if (!data) { data = {}; } @@ -345,7 +346,7 @@ export class DashboardModel { defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions ) { const originalVariables = this.originalTemplating; - const currentVariables = this.getVariablesFromState(); + const currentVariables = this.getVariablesFromState(this.uid); copy.templating = { list: currentVariables.map((variable) => @@ -373,7 +374,7 @@ export class DashboardModel { timeRangeUpdated(timeRange: TimeRange) { this.events.publish(new TimeRangeUpdatedEvent(timeRange)); - dispatch(onTimeRangeUpdated(timeRange)); + dispatch(onTimeRangeUpdated(this.uid, timeRange)); } startRefresh(event: VariablesChangedEvent = { refreshAll: true, panelIds: [] }) { @@ -1084,11 +1085,11 @@ export class DashboardModel { return; } - this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState()); + this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState(this.uid)); } hasVariableValuesChanged() { - return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState()); + return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState(this.uid)); } autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { @@ -1163,7 +1164,7 @@ export class DashboardModel { } getVariables = () => { - return this.getVariablesFromState(); + return this.getVariablesFromState(this.uid); }; canAddAnnotations() { @@ -1178,7 +1179,7 @@ export class DashboardModel { } private getPanelRepeatVariable(panel: PanelModel) { - return this.getVariablesFromState().find((variable) => variable.name === panel.repeat); + return this.getVariablesFromState(this.uid).find((variable) => variable.name === panel.repeat); } private isSnapshotTruthy() { @@ -1186,7 +1187,7 @@ export class DashboardModel { } private hasVariables() { - return this.getVariablesFromState().length > 0; + return this.getVariablesFromState(this.uid).length > 0; } private hasVariablesChanged(originalVariables: any[], currentVariables: any[]): boolean { diff --git a/public/app/features/dashboard/state/TimeModel.ts b/public/app/features/dashboard/state/TimeModel.ts new file mode 100644 index 00000000000..b7df209d6f7 --- /dev/null +++ b/public/app/features/dashboard/state/TimeModel.ts @@ -0,0 +1,10 @@ +import { TimeRange, TimeZone } from '@grafana/data'; + +export interface TimeModel { + time: any; + fiscalYearStartMonth?: number; + refresh: any; + timepicker: any; + getTimezone(): TimeZone; + timeRangeUpdated(timeRange: TimeRange): void; +} diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 9fdd7d1b5eb..9ae183fc475 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -119,24 +119,24 @@ export const cleanUpDashboardAndVariables = (): ThunkResult => (dispatch, if (dashboard) { dashboard.destroy(); + dispatch(cancelVariables(dashboard.uid)); } getTimeSrv().stopAutoRefresh(); dispatch(cleanUpDashboard()); - dispatch(cancelVariables()); }; export const updateTimeZoneDashboard = (timeZone: TimeZone): ThunkResult => (dispatch) => { dispatch(updateTimeZoneForSession(timeZone)); - getTimeSrv().refreshDashboard(); + getTimeSrv().refreshTimeModel(); }; export const updateWeekStartDashboard = (weekStart: string): ThunkResult => (dispatch) => { dispatch(updateWeekStartForSession(weekStart)); - getTimeSrv().refreshDashboard(); + getTimeSrv().refreshTimeModel(); }; diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index f7083989a6b..0bcb74ef8d2 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -11,7 +11,7 @@ import { Echo } from '../../../core/services/echo/Echo'; import { variableAdapters } from 'app/features/variables/adapters'; import { createConstantVariableAdapter } from 'app/features/variables/constant/adapter'; import { constantBuilder } from 'app/features/variables/shared/testing/builders'; -import { variablesInitTransaction } from '../../variables/state/transactionReducer'; +import { initialTransactionState, variablesInitTransaction } from '../../variables/state/transactionReducer'; import { keybindingSrv } from 'app/core/services/keybindingSrv'; import { getTimeSrv, setTimeSrv } from '../services/TimeSrv'; import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv'; @@ -22,6 +22,7 @@ import { } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { emptyResult } from '../../query/state/DashboardQueryRunner/utils'; import { TransactionStatus } from '../../variables/types'; +import { getPreloadedState } from '../../variables/state/helpers'; jest.mock('app/core/services/backend_srv'); jest.mock('app/features/dashboard/services/TimeSrv', () => { @@ -53,7 +54,7 @@ interface ScenarioContext { } type ScenarioFn = (ctx: ScenarioContext) => void; - +const DASH_UID = 'DGmvKKxZz'; function describeInitScenario(description: string, scenarioFn: ScenarioFn) { describe(description, () => { const loaderSrv = { @@ -83,6 +84,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { templating: { list: [constantBuilder().build()], }, + uid: DASH_UID, }, })), }; @@ -100,7 +102,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { const ctx: ScenarioContext = { args: { - urlUid: 'DGmvKKxZz', + urlUid: DASH_UID, fixUrl: false, routeName: DashboardRoutes.Normal, }, @@ -120,10 +122,10 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { queries: [], }, }, - templating: { + ...getPreloadedState(DASH_UID, { variables: {}, - transaction: { uid: 'DGmvKKxZz', status: TransactionStatus.Completed }, - }, + transaction: { ...initialTransactionState, uid: DASH_UID, status: TransactionStatus.Completed }, + }), }, setup: (fn: () => void) => { setupFn = fn; @@ -258,7 +260,7 @@ describeInitScenario('Initializing existing dashboard', (ctx) => { }); it('Should initialize redux variables if newVariables is enabled', () => { - expect(ctx.actions[2].type).toBe(variablesInitTransaction.type); + expect(ctx.actions[2].payload.action.type).toBe(variablesInitTransaction.type); }); }); @@ -287,3 +289,14 @@ describeInitScenario('Initializing previously canceled dashboard initialization' expect(getDashboardQueryRunner().run).toBeCalled(); }); }); + +describeInitScenario('Initializing snapshot dashboard', (ctx) => { + ctx.setup(() => { + ctx.args.urlUid = undefined; + }); + + it('Should send action initVariablesTransaction with correct payload', () => { + expect(ctx.actions[2].payload.action.type).toBe(variablesInitTransaction.type); + expect(ctx.actions[2].payload.action.payload.uid).toBe(DASH_UID); + }); +}); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 876e2170e3a..91dc7c58280 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -23,6 +23,8 @@ import { emitDashboardViewEvent } from './analyticsProcessor'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { config, locationService } from '@grafana/runtime'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { getIfExistsLastKey } from '../../variables/state/selectors'; +import { toStateKey } from 'app/features/variables/utils'; export interface InitDashboardArgs { urlUid?: string; @@ -155,15 +157,16 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { timeSrv.init(dashboard); + const dashboardUid = toStateKey(args.urlUid ?? dashboard.uid); // template values service needs to initialize completely before the rest of the dashboard can load - await dispatch(initVariablesTransaction(args.urlUid!, dashboard)); + await dispatch(initVariablesTransaction(dashboardUid, dashboard)); // DashboardQueryRunner needs to run after all variables have been resolved so that any annotation query including a variable // will be correctly resolved const runner = createDashboardQueryRunner({ dashboard, timeSrv }); runner.run({ dashboard, range: timeSrv.timeRange() }); - if (getState().templating.transaction.uid !== args.urlUid) { + if (getIfExistsLastKey(getState()) !== dashboardUid) { // if a previous dashboard has slow running variable queries the batch uid will be the new one // but the args.urlUid will be the same as before initVariablesTransaction was called so then we can't continue initializing // the previous dashboard. diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts index 15be676a44f..24c82d75074 100644 --- a/public/app/features/datasources/state/navModel.ts +++ b/public/app/features/datasources/state/navModel.ts @@ -76,9 +76,9 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat const analytics = { active: false, icon: 'info-circle', - id: `datasource-insights-${dataSource.id}`, + id: `datasource-insights-${dataSource.uid}`, text: 'Insights', - url: `datasources/edit/${dataSource.id}/insights`, + url: `datasources/edit/${dataSource.uid}/insights`, }; if (featureEnabled('analytics')) { diff --git a/public/app/features/explore/Logs.test.tsx b/public/app/features/explore/Logs.test.tsx new file mode 100644 index 00000000000..82d32ffbb29 --- /dev/null +++ b/public/app/features/explore/Logs.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LoadingState, LogLevel, LogRowModel, MutableDataFrame, toUtc } from '@grafana/data'; + +import { Logs } from './Logs'; + +describe('Logs', () => { + const setup = (propOverrides?: object) => { + const rows = [ + makeLog({ uid: '1', timeEpochMs: 1 }), + makeLog({ uid: '2', timeEpochMs: 2 }), + makeLog({ uid: '3', timeEpochMs: 3 }), + ]; + + return render( + {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + /> + ); + }; + + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should render logs', () => { + setup(); + const logsSection = screen.getByTestId('logRows'); + let logRows = logsSection.querySelectorAll('tr'); + expect(logRows.length).toBe(3); + expect(logRows[0].textContent).toContain('log message 3'); + expect(logRows[2].textContent).toContain('log message 1'); + }); + + it('should flip the order', () => { + setup(); + const oldestFirstSelection = screen.getByLabelText('Oldest first'); + fireEvent.click(oldestFirstSelection); + jest.advanceTimersByTime(1000); + const logsSection = screen.getByTestId('logRows'); + let logRows = logsSection.querySelectorAll('tr'); + expect(logRows.length).toBe(3); + expect(logRows[0].textContent).toContain('log message 1'); + expect(logRows[2].textContent).toContain('log message 3'); + }); +}); + +const makeLog = (overrides: Partial): LogRowModel => { + const uid = overrides.uid || '1'; + const entry = `log message ${uid}`; + return { + uid, + entryFieldIndex: 0, + rowIndex: 0, + dataFrame: new MutableDataFrame(), + logLevel: LogLevel.debug, + entry, + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + raw: entry, + timeFromNow: '', + timeEpochMs: 1, + timeEpochNs: '1000000', + timeLocal: '', + timeUtc: '', + ...overrides, + }; +}; diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 9d06682c351..1d6d28b051d 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -43,6 +43,7 @@ const SETTINGS_KEYS = { showTime: 'grafana.explore.logs.showTime', wrapLogMessage: 'grafana.explore.logs.wrapLogMessage', prettifyLogMessage: 'grafana.explore.logs.prettifyLogMessage', + logsSortOrder: 'grafana.explore.logs.sortOrder', }; interface Props extends Themeable2 { @@ -96,7 +97,7 @@ class UnthemedLogs extends PureComponent { prettifyLogMessage: store.getBool(SETTINGS_KEYS.prettifyLogMessage, false), dedupStrategy: LogsDedupStrategy.none, hiddenLogLevels: [], - logsSortOrder: null, + logsSortOrder: store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending, isFlipping: false, showDetectedFields: [], forceEscape: false, @@ -117,10 +118,10 @@ class UnthemedLogs extends PureComponent { // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs this.flipOrderTimer = window.setTimeout(() => { this.setState((prevState) => { - if (prevState.logsSortOrder === null || prevState.logsSortOrder === LogsSortOrder.Descending) { - return { logsSortOrder: LogsSortOrder.Ascending }; - } - return { logsSortOrder: LogsSortOrder.Descending }; + const newSortOrder = + prevState.logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; + store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); + return { logsSortOrder: newSortOrder }; }); }, 0); this.cancelFlippingTimer = window.setTimeout(() => this.setState({ isFlipping: false }), 1000); @@ -291,7 +292,6 @@ class UnthemedLogs extends PureComponent { const { dedupedRows, dedupCount } = this.dedupRows(filteredLogs, dedupStrategy); const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; - return ( <> {logsSeries && logsSeries.length ? ( @@ -366,16 +366,26 @@ class UnthemedLogs extends PureComponent {
- + + +
{ clearDetectedFields={this.clearDetectedFields} />
-
+
{ - return () => clearCache(); - // We can't enforce the eslint rule here because we only want to run when component unmounts. + clearCache(); + // We can't enforce the eslint rule here because we only want to run when component is mounted. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 31bb1b1da06..a40de3799cb 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -38,6 +38,7 @@ import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state import { getDataSourceSrv } from '@grafana/runtime'; import { getRichHistory } from '../../../core/utils/richHistory'; import { richHistoryUpdatedAction, stateSave } from './main'; +import { keybindingSrv } from 'app/core/services/keybindingSrv'; // // Actions and Payloads @@ -172,6 +173,8 @@ export function initializeExplore( } dispatch(updateTime({ exploreId })); + keybindingSrv.setupTimeRangeBindings(false); + if (instance) { // We do not want to add the url to browser history on init because when the pane is initialised it's because // we already have something in the url. Adding basically the same state as additional history item prevents diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 2accb2fa55b..9aa8bcad2ac 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -21,6 +21,7 @@ import { DataQueryResponse, DataSourceApi, DataSourceJsonData, + DataSourceWithLogsVolumeSupport, DefaultTimeZone, LoadingState, MutableDataFrame, @@ -54,6 +55,7 @@ const defaultInitialState = { datasourceInstance: { query: jest.fn(), getRef: jest.fn(), + getLogsVolumeDataProvider: jest.fn(), meta: { id: 'something', }, @@ -101,6 +103,24 @@ describe('runQueries', () => { expect(getState().explore[ExploreId.left].graphResult).toBeDefined(); }); + it('should modify the request-id for log-volume queries', async () => { + setTimeSrv({ init() {} } as any); + const { dispatch, getState } = configureStore({ + ...(defaultInitialState as any), + }); + setupQueryResponse(getState()); + await dispatch(runQueries(ExploreId.left)); + + const state = getState().explore[ExploreId.left]; + expect(state.queryResponse.request?.requestId).toBe('explore_left'); + const datasource = state.datasourceInstance as any as DataSourceWithLogsVolumeSupport; + expect(datasource.getLogsVolumeDataProvider).toBeCalledWith( + expect.objectContaining({ + requestId: 'explore_left_log_volume', + }) + ); + }); + it('should set state to done if query completes without emitting', async () => { setTimeSrv({ init() {} } as any); const { dispatch, getState } = configureStore({ diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 9825a6a1dfc..730d81177a7 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -497,7 +497,11 @@ export const runQueries = ( ); dispatch(cleanLogsVolumeAction({ exploreId })); } else if (hasLogsVolumeSupport(datasourceInstance)) { - const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(transaction.request); + const sourceRequest = { + ...transaction.request, + requestId: transaction.request.requestId + '_log_volume', + }; + const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(sourceRequest); dispatch( storeLogsVolumeDataProviderAction({ exploreId, diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 9d8e7ab3c4b..5ab326f4646 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -14,7 +14,7 @@ import { ExploreItemState, ThunkResult } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; -import { DashboardModel } from 'app/features/dashboard/state'; +import { TimeModel } from '../../dashboard/state/TimeModel'; import { runQueries } from './query'; import { syncTimesAction, stateSave } from './main'; @@ -95,14 +95,17 @@ export const updateTime = (config: { const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth); const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + const timeModel: TimeModel = { + time: range.raw, + refresh: false, + timepicker: {}, + getTimezone: () => timeZone, + timeRangeUpdated: (rawTimeRange: RawTimeRange) => { + dispatch(updateTimeRange({ exploreId: exploreId, rawRange: rawTimeRange })); + }, + }; - getTimeSrv().init( - new DashboardModel({ - time: range.raw, - refresh: false, - timeZone, - }) - ); + getTimeSrv().init(timeModel); dispatch(changeRangeAction({ exploreId, range, absoluteRange })); }; diff --git a/public/app/features/expressions/ExpressionDatasource.test.ts b/public/app/features/expressions/ExpressionDatasource.test.ts new file mode 100644 index 00000000000..139ec38c23c --- /dev/null +++ b/public/app/features/expressions/ExpressionDatasource.test.ts @@ -0,0 +1,37 @@ +import { DataSourceInstanceSettings } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { ExpressionDatasourceApi } from './ExpressionDatasource'; +import { ExpressionQueryType } from './types'; + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as unknown as object), + getBackendSrv: () => backendSrv, + getTemplateSrv: () => ({ + replace: (val: string) => (val ? val.replace('$input', '10').replace('$window', '10s') : val), + }), +})); + +describe('ExpressionDatasourceApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('expression queries with template variables', () => { + it('should interpolate template variables in expression query', () => { + const ds = new ExpressionDatasourceApi({} as DataSourceInstanceSettings); + const query = ds.applyTemplateVariables( + { type: ExpressionQueryType.math, refId: 'B', expression: '$input + 5 + $A' }, + {} + ); + expect(query.expression).toBe('10 + 5 + $A'); + }); + it('should interpolate template variables in expression query', () => { + const ds = new ExpressionDatasourceApi({} as DataSourceInstanceSettings); + const query = ds.applyTemplateVariables( + { type: ExpressionQueryType.resample, refId: 'B', window: '$window' }, + {} + ); + expect(query.window).toBe('10s'); + }); + }); +}); diff --git a/public/app/features/expressions/ExpressionDatasource.ts b/public/app/features/expressions/ExpressionDatasource.ts index cfc5be8e385..15983e97bf5 100644 --- a/public/app/features/expressions/ExpressionDatasource.ts +++ b/public/app/features/expressions/ExpressionDatasource.ts @@ -1,8 +1,16 @@ -import { DataSourceInstanceSettings, DataSourcePluginMeta, PluginType } from '@grafana/data'; +import { + DataQueryRequest, + DataQueryResponse, + DataSourceInstanceSettings, + DataSourcePluginMeta, + PluginType, + ScopedVars, +} from '@grafana/data'; import { ExpressionQuery, ExpressionQueryType } from './types'; import { ExpressionQueryEditor } from './ExpressionQueryEditor'; -import { DataSourceWithBackend } from '@grafana/runtime'; +import { DataSourceWithBackend, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; +import { Observable, from, mergeMap } from 'rxjs'; /** * This is a singleton instance that just pretends to be a DataSource @@ -12,10 +20,34 @@ export class ExpressionDatasourceApi extends DataSourceWithBackend { + const templateSrv = getTemplateSrv(); + return { + ...query, + expression: templateSrv.replace(query.expression, scopedVars), + window: templateSrv.replace(query.window, scopedVars), + }; + } + getCollapsedText(query: ExpressionQuery) { return `Expression: ${query.type}`; } + query(request: DataQueryRequest): Observable { + let targets = request.targets.map(async (query: ExpressionQuery): Promise => { + const ds = await getDataSourceSrv().get(query.datasource); + + if (!ds.interpolateVariablesInQueries) { + return query; + } + + return ds?.interpolateVariablesInQueries([query], {})[0] as ExpressionQuery; + }); + + let sub = from(Promise.all(targets)); + return sub.pipe(mergeMap((t) => super.query({ ...request, targets: t }))); + } + newQuery(query?: Partial): ExpressionQuery { return { refId: '--', // Replaced with query diff --git a/public/app/features/geo/format/utils.ts b/public/app/features/geo/format/utils.ts index 8dd793dae6b..37ea8097d29 100644 --- a/public/app/features/geo/format/utils.ts +++ b/public/app/features/geo/format/utils.ts @@ -1,5 +1,5 @@ import { ArrayVector, Field, FieldConfig, FieldType } from '@grafana/data'; -import { getCenterPoint } from 'app/core/components/TransformersUI/spatial/utils'; +import { getCenterPoint } from 'app/features/transformers/spatial/utils'; import { Geometry, LineString, Point } from 'ol/geom'; import { fromLonLat } from 'ol/proj'; import { Gazetteer } from '../gazetteer/gazetteer'; diff --git a/public/app/features/geo/gazetteer/gazetteer.test.ts b/public/app/features/geo/gazetteer/gazetteer.test.ts index 4c9f455817e..7bff83cfb1c 100644 --- a/public/app/features/geo/gazetteer/gazetteer.test.ts +++ b/public/app/features/geo/gazetteer/gazetteer.test.ts @@ -1,4 +1,4 @@ -import { getCenterPointWGS84 } from 'app/core/components/TransformersUI/spatial/utils'; +import { getCenterPointWGS84 } from 'app/features/transformers/spatial/utils'; import { getGazetteer } from './gazetteer'; let backendResults: any = { hello: 'world' }; diff --git a/public/app/features/inspector/InspectErrorTab.test.tsx b/public/app/features/inspector/InspectErrorTab.test.tsx new file mode 100644 index 00000000000..b04e1283d35 --- /dev/null +++ b/public/app/features/inspector/InspectErrorTab.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { InspectErrorTab } from './InspectErrorTab'; + +describe('InspectErrorTab', () => { + it('should return null when error does not exist', () => { + const { container } = render(); + expect(container.childElementCount).toEqual(0); + }); + + it('should return a jsonFormatter object of error.data if it exists', () => { + const error = { + data: { + message: 'This is an error', + error: 'my error', + }, + }; + render(); + expect(screen.getByText('This is an error')).toBeInTheDocument(); + expect(screen.getByText('error:')).toBeInTheDocument(); + expect(screen.getByText('"my error"')).toBeInTheDocument(); + }); + + it('should return a jsonFormatter object of error.message if it exists and data does not exist', () => { + const error = { + message: + '{ "error": { "code": "BadRequest", "message": "Please provide below info when asking for support.", "details": [] } }', + }; + const { container } = render(); + expect(container.childElementCount).toEqual(1); + expect(screen.getByText('code:')).toBeInTheDocument(); + expect(screen.getByText('"BadRequest"')).toBeInTheDocument(); + expect(screen.getByText('"Please provide below info when asking for support."')).toBeInTheDocument(); + }); + + it('should return an h3 and jsonFormatter object of error.message if it exists and data does not exist', () => { + const error = { + message: + '400 BadRequest, Error from Azure: { "error": { "code": "BadRequest", "message": "Please provide below info when asking for support.", "details": [] } }', + }; + const { container } = render(); + expect(container.childElementCount).toEqual(2); + expect(screen.getByRole('heading', { name: '400 BadRequest, Error from Azure:' })).toBeInTheDocument(); + expect(screen.getByText('code:')).toBeInTheDocument(); + expect(screen.getByText('"BadRequest"')).toBeInTheDocument(); + }); + + [ + '{ invalidJSON{', + "hello, I am an error that's just text, no json at all, altoough I do mention template variables {{test}}", + 'and I am a simple string', + ].forEach((errMsg) => { + it(`should return error.message error.data does not exist nd error.message cannot be parsed - ${errMsg} `, () => { + const error = { + message: errMsg, + }; + render(); + expect(screen.queryByRole('heading')).toBeNull(); + expect(screen.getByText(errMsg)).toBeInTheDocument(); + }); + }); + + it('should return a jsonFormatter object of error if it has no .data and no .message', () => { + const error = { + status: '400', + }; + const { container } = render(); + expect(container.childElementCount).toEqual(1); + expect(screen.getByText('status:')).toBeInTheDocument(); + expect(screen.getByText('"400"')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/inspector/InspectErrorTab.tsx b/public/app/features/inspector/InspectErrorTab.tsx index dcb00122cf5..2c747d18f36 100644 --- a/public/app/features/inspector/InspectErrorTab.tsx +++ b/public/app/features/inspector/InspectErrorTab.tsx @@ -6,6 +6,19 @@ interface InspectErrorTabProps { error?: DataQueryError; } +const parseErrorMessage = (message: string): { msg: string; json?: any } => { + try { + const [msg, json] = message.split(/(\{.+)/); + const jsonError = JSON.parse(json); + return { + msg, + json: jsonError, + }; + } catch { + return { msg: message }; + } +}; + export const InspectErrorTab: React.FC = ({ error }) => { if (!error) { return null; @@ -18,5 +31,18 @@ export const InspectErrorTab: React.FC = ({ error }) => { ); } - return
{error.message}
; + if (error.message) { + const { msg, json } = parseErrorMessage(error.message); + if (!json) { + return
{msg}
; + } else { + return ( + <> + {msg !== '' &&

{msg}

} + + + ); + } + } + return ; }; diff --git a/public/app/features/users/InviteeRow.tsx b/public/app/features/invites/InviteeRow.tsx similarity index 100% rename from public/app/features/users/InviteeRow.tsx rename to public/app/features/invites/InviteeRow.tsx diff --git a/public/app/features/users/InviteesTable.test.tsx b/public/app/features/invites/InviteesTable.test.tsx similarity index 91% rename from public/app/features/users/InviteesTable.test.tsx rename to public/app/features/invites/InviteesTable.test.tsx index ad464f81a2f..e776b52e714 100644 --- a/public/app/features/users/InviteesTable.test.tsx +++ b/public/app/features/invites/InviteesTable.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import InviteesTable, { Props } from './InviteesTable'; import { Invitee } from 'app/types'; -import { getMockInvitees } from './__mocks__/userMocks'; +import { getMockInvitees } from '../users/__mocks__/userMocks'; const setup = (propOverrides?: object) => { const props: Props = { diff --git a/public/app/features/users/InviteesTable.tsx b/public/app/features/invites/InviteesTable.tsx similarity index 100% rename from public/app/features/users/InviteesTable.tsx rename to public/app/features/invites/InviteesTable.tsx diff --git a/public/app/features/users/SignupInvited.test.tsx b/public/app/features/invites/SignupInvited.test.tsx similarity index 100% rename from public/app/features/users/SignupInvited.test.tsx rename to public/app/features/invites/SignupInvited.test.tsx diff --git a/public/app/features/users/SignupInvited.tsx b/public/app/features/invites/SignupInvited.tsx similarity index 100% rename from public/app/features/users/SignupInvited.tsx rename to public/app/features/invites/SignupInvited.tsx diff --git a/public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap b/public/app/features/invites/__snapshots__/InviteesTable.test.tsx.snap similarity index 100% rename from public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap rename to public/app/features/invites/__snapshots__/InviteesTable.test.tsx.snap diff --git a/public/app/features/invites/state/actions.ts b/public/app/features/invites/state/actions.ts new file mode 100644 index 00000000000..0e7c891da25 --- /dev/null +++ b/public/app/features/invites/state/actions.ts @@ -0,0 +1,23 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; +import { FormModel } from 'app/features/org/UserInviteForm'; +import { AccessControlAction, createAsyncThunk, Invitee } from 'app/types'; + +export const fetchInvitees = createAsyncThunk('users/fetchInvitees', async () => { + if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) { + return []; + } + + const invitees: Invitee[] = await getBackendSrv().get('/api/org/invites'); + return invitees; +}); + +export const addInvitee = createAsyncThunk('users/addInvitee', async (addInviteForm: FormModel, { dispatch }) => { + await getBackendSrv().post(`/api/org/invites`, addInviteForm); + await dispatch(fetchInvitees()); +}); + +export const revokeInvite = createAsyncThunk('users/revokeInvite', async (code: string) => { + await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {}); + return code; +}); diff --git a/public/app/features/invites/state/reducers.test.ts b/public/app/features/invites/state/reducers.test.ts new file mode 100644 index 00000000000..e4563709220 --- /dev/null +++ b/public/app/features/invites/state/reducers.test.ts @@ -0,0 +1,44 @@ +import { keyBy } from 'lodash'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { initialState, invitesReducer } from './reducers'; +import { fetchInvitees, revokeInvite } from './actions'; +import { getMockInvitees } from 'app/features/users/__mocks__/userMocks'; + +describe('inviteesReducer', () => { + describe('when fetchInvitees is dispatched', () => { + it('then state should be correct', () => { + const invitees = getMockInvitees(1); + reducerTester() + .givenReducer(invitesReducer, { ...initialState }) + .whenActionIsDispatched(fetchInvitees.fulfilled(invitees, '')) + .thenStateShouldEqual({ + entities: keyBy(invitees, 'code'), + ids: invitees.map((i) => i.code), + status: 'succeeded', + }); + }); + }); + + describe('when revokeInvite is dispatched', () => { + it('then state should be correct', () => { + const invitees = getMockInvitees(1); + + const fakeInitialState: typeof initialState = { + entities: keyBy(invitees, 'code'), + ids: invitees.map((i) => i.code), + status: 'succeeded', + }; + + reducerTester() + .givenReducer(invitesReducer, fakeInitialState) + .whenActionIsDispatched(revokeInvite.fulfilled(invitees[0].code, '', '')) + .thenStateShouldEqual({ + entities: { + [invitees[1].code]: invitees[1], + }, + ids: [invitees[1].code], + status: 'succeeded', + }); + }); + }); +}); diff --git a/public/app/features/invites/state/reducers.ts b/public/app/features/invites/state/reducers.ts new file mode 100644 index 00000000000..bad26fdc938 --- /dev/null +++ b/public/app/features/invites/state/reducers.ts @@ -0,0 +1,38 @@ +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; +import { Invitee } from 'app/types'; +import { fetchInvitees, revokeInvite } from './actions'; + +export type Status = 'idle' | 'loading' | 'succeeded' | 'failed'; + +const invitesAdapter = createEntityAdapter({ selectId: (invite: Invitee) => invite.code }); +export const selectors = invitesAdapter.getSelectors(); +export const initialState = invitesAdapter.getInitialState<{ status: Status }>({ status: 'idle' }); + +const invitesSlice = createSlice({ + name: 'invites', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchInvitees.pending, (state) => { + state.status = 'loading'; + }) + .addCase(fetchInvitees.fulfilled, (state, { payload: invites }) => { + invitesAdapter.setAll(state, invites); + state.status = 'succeeded'; + }) + .addCase(fetchInvitees.rejected, (state) => { + state.status = 'failed'; + }) + .addCase(revokeInvite.fulfilled, (state, { payload: inviteCode }) => { + invitesAdapter.removeOne(state, inviteCode); + state.status = 'succeeded'; + }); + }, +}); + +export const invitesReducer = invitesSlice.reducer; + +export default { + invites: invitesReducer, +}; diff --git a/public/app/features/invites/state/selectors.ts b/public/app/features/invites/state/selectors.ts new file mode 100644 index 00000000000..7a9468dbde9 --- /dev/null +++ b/public/app/features/invites/state/selectors.ts @@ -0,0 +1,11 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { selectors } from './reducers'; + +export const { selectAll, selectById, selectTotal } = selectors; + +const selectQuery = (_: any, query: string) => query; +export const selectInvitesMatchingQuery = createSelector([selectAll, selectQuery], (invites, searchQuery) => { + const regex = new RegExp(searchQuery, 'i'); + const matches = invites.filter((invite) => regex.test(invite.name) || regex.test(invite.email)); + return matches; +}); diff --git a/public/app/features/org/UserInviteForm.tsx b/public/app/features/org/UserInviteForm.tsx index 9338884c4e8..3ab5073d6c9 100644 --- a/public/app/features/org/UserInviteForm.tsx +++ b/public/app/features/org/UserInviteForm.tsx @@ -10,11 +10,11 @@ import { Field, InputControl, } from '@grafana/ui'; -import { getConfig } from 'app/core/config'; -import { OrgRole } from 'app/types'; import { locationService } from '@grafana/runtime'; import { locationUtil } from '@grafana/data'; -import { userInviteSubmit } from './api'; +import { getConfig } from 'app/core/config'; +import { OrgRole, useDispatch } from 'app/types'; +import { addInvitee } from '../invites/state/actions'; const roles = [ { label: 'Viewer', value: OrgRole.Viewer }, @@ -22,11 +22,6 @@ const roles = [ { label: 'Admin', value: OrgRole.Admin }, ]; -const onSubmit = async (formData: FormModel) => { - await userInviteSubmit(formData); - locationService.push('/org/users/'); -}; - export interface FormModel { role: OrgRole; name: string; @@ -35,12 +30,19 @@ export interface FormModel { email: string; } +const defaultValues: FormModel = { + name: '', + email: '', + role: OrgRole.Editor, + sendEmail: true, +}; + export const UserInviteForm = () => { - const defaultValues: FormModel = { - name: '', - email: '', - role: OrgRole.Editor, - sendEmail: true, + const dispatch = useDispatch(); + + const onSubmit = async (formData: FormModel) => { + await dispatch(addInvitee(formData)).unwrap(); + locationService.push('/org/users/'); }; return ( diff --git a/public/app/features/org/api.ts b/public/app/features/org/api.ts deleted file mode 100644 index 18f8c8e901b..00000000000 --- a/public/app/features/org/api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { appEvents } from 'app/core/core'; -import { FormModel } from './UserInviteForm'; -import { AppEvents } from '@grafana/data'; - -export const userInviteSubmit = async (formData: FormModel) => { - try { - await getBackendSrv().post('/api/org/invites', formData); - } catch (err) { - appEvents.emit(AppEvents.alertError, ['Failed to send invitation.', err.message]); - } -}; diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index b32b5b62b15..b2b30096045 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -32,7 +32,7 @@ describe('linkSrv', () => { _dashboard.refresh = false; setTimeSrv(timeSrv); - templateSrv = initTemplateSrv([ + templateSrv = initTemplateSrv('key', [ { type: 'query', name: 'home', current: { value: '127.0.0.1' } }, { type: 'query', name: 'server1', current: { value: '192.168.0.100' } }, ]); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 68a47c7ebad..e23bf4b0b0e 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -73,6 +73,8 @@ export const install = createAsyncThunk( return { id, changes } as Update; } catch (e) { + console.error(e); + return thunkApi.rejectWithValue('Unknown error.'); } } @@ -90,6 +92,8 @@ export const uninstall = createAsyncThunk(`${STATE_PREFIX}/uninstall`, async (id changes: { isInstalled: false, installedVersion: undefined }, } as Update; } catch (e) { + console.error(e); + return thunkApi.rejectWithValue('Unknown error.'); } }); diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 66cfd1d6bb0..ebe372a6fff 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -45,15 +45,13 @@ import * as timeseriesPanel from 'app/plugins/panel/timeseries/module'; import * as stateTimelinePanel from 'app/plugins/panel/state-timeline/module'; import * as statusHistoryPanel from 'app/plugins/panel/status-history/module'; import * as candlestickPanel from 'app/plugins/panel/candlestick/module'; -import * as graphPanel from 'app/plugins/panel/graph/module'; import * as xyChartPanel from 'app/plugins/panel/xychart/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; import * as annoListPanel from 'app/plugins/panel/annolist/module'; -import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; +import * as heatmapPanelNG from 'app/plugins/panel/heatmap-new/module'; import * as tablePanel from 'app/plugins/panel/table/module'; -import * as oldTablePanel from 'app/plugins/panel/table-old/module'; import * as statPanel from 'app/plugins/panel/stat/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; import * as gaugePanel from 'app/plugins/panel/gauge/module'; @@ -73,6 +71,11 @@ import * as alertGroupsPanel from 'app/plugins/panel/alertGroups/module'; const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module'); const canvasPanel = async () => await import(/* webpackChunkName: "canvasPanel" */ 'app/plugins/panel/canvas/module'); const iconPanel = async () => await import(/* webpackChunkName: "iconPanel" */ 'app/plugins/panel/icon/module'); +const graphPanel = async () => await import(/* webpackChunkName: "graphPlugin" */ 'app/plugins/panel/graph/module'); +const heatmapPanel = async () => + await import(/* webpackChunkName: "heatmapPlugin" */ 'app/plugins/panel/heatmap/module'); +const tableOldPanel = async () => + await import(/* webpackChunkName: "tableOldPlugin" */ 'app/plugins/panel/table-old/module'); const builtInPlugins: any = { 'app/plugins/datasource/graphite/module': graphitePlugin, @@ -111,8 +114,9 @@ const builtInPlugins: any = { 'app/plugins/panel/alertlist/module': alertListPanel, 'app/plugins/panel/annolist/module': annoListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, + 'app/plugins/panel/heatmap-new/module': heatmapPanelNG, 'app/plugins/panel/table/module': tablePanel, - 'app/plugins/panel/table-old/module': oldTablePanel, + 'app/plugins/panel/table-old/module': tableOldPanel, 'app/plugins/panel/news/module': newsPanel, 'app/plugins/panel/live/module': livePanel, 'app/plugins/panel/stat/module': statPanel, diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 8bd0ded2976..831a5947762 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -1,9 +1,7 @@ // eslint-disable-next-line lodash/import-scope import _ from 'lodash'; -import * as sdk from 'app/plugins/sdk'; 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'; // Experimental module exports @@ -21,12 +19,10 @@ import * as redux from 'redux'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import TableModel from 'app/core/table_model'; -import { coreModule } from 'app/angular/core_module'; import { appEvents, contextSrv } from 'app/core/core'; import * as flatten from 'app/core/utils/flatten'; import * as ticks from 'app/core/utils/ticks'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; -import { promiseToDigest } from 'app/angular/promiseToDigest'; import impressionSrv from 'app/core/services/impression_srv'; import builtInPlugins from './built_in_plugins'; import * as d3 from 'd3'; @@ -75,7 +71,7 @@ grafanaRuntime.SystemJS.config({ }, }); -function exposeToPlugin(name: string, component: any) { +export function exposeToPlugin(name: string, component: any) { grafanaRuntime.SystemJS.registerDynamic(name, [], true, (require: any, exports: any, module: { exports: any }) => { module.exports = component; }); @@ -87,7 +83,6 @@ exposeToPlugin('@grafana/runtime', grafanaRuntime); exposeToPlugin('lodash', _); exposeToPlugin('moment', moment); exposeToPlugin('jquery', jquery); -exposeToPlugin('angular', angular); exposeToPlugin('d3', d3); exposeToPlugin('rxjs', rxjs); exposeToPlugin('rxjs/operators', rxjsOperators); @@ -120,24 +115,16 @@ exposeToPlugin('app/core/services/backend_srv', { getBackendSrv, }); -exposeToPlugin('app/plugins/sdk', sdk); exposeToPlugin('app/core/utils/datemath', grafanaData.dateMath); exposeToPlugin('app/core/utils/flatten', flatten); exposeToPlugin('app/core/utils/kbn', kbn); exposeToPlugin('app/core/utils/ticks', ticks); -exposeToPlugin('app/core/utils/promiseToDigest', { - promiseToDigest: promiseToDigest, - __esModule: true, -}); - exposeToPlugin('app/core/config', config); exposeToPlugin('app/core/time_series', TimeSeries); exposeToPlugin('app/core/time_series2', TimeSeries); exposeToPlugin('app/core/table_model', TableModel); exposeToPlugin('app/core/app_events', appEvents); -exposeToPlugin('app/core/core_module', coreModule); exposeToPlugin('app/core/core', { - coreModule: coreModule, appEvents: appEvents, contextSrv: contextSrv, __esModule: true, diff --git a/public/app/features/query/components/QueryEditorRow.test.ts b/public/app/features/query/components/QueryEditorRow.test.ts index b754fa5710a..6117923d66c 100644 --- a/public/app/features/query/components/QueryEditorRow.test.ts +++ b/public/app/features/query/components/QueryEditorRow.test.ts @@ -54,10 +54,41 @@ describe('filterPanelDataToQuery', () => { const panelData = filterPanelDataToQuery(withError, 'B'); expect(panelData).toBeDefined(); + expect(panelData?.state).toBe(LoadingState.Error); + expect(panelData?.error).toBe(withError.error); + }); - // @ts-ignore typescript doesn't understand that panelData can't be undefined here - expect(panelData.state).toBe(LoadingState.Error); - // @ts-ignore typescript doesn't understand that panelData can't be undefined here - expect(panelData.error).toBe(withError.error); + it('should set the state to done if the frame has no errors', () => { + const withError = { + ...data, + }; + withError.state = LoadingState.Error; + + const panelDataB = filterPanelDataToQuery(withError, 'B'); + expect(panelDataB?.series.length).toBe(3); + expect(panelDataB?.series[0].refId).toBe('B'); + expect(panelDataB?.state).toBe(LoadingState.Error); + + const panelDataA = filterPanelDataToQuery(withError, 'A'); + expect(panelDataA?.series.length).toBe(1); + expect(panelDataA?.series[0].refId).toBe('A'); + expect(panelDataA?.state).toBe(LoadingState.Done); + }); + + it('should not set the state to done if the frame is loading and has no errors', () => { + const loadingData: PanelData = { + state: LoadingState.Loading, + series: [ + toDataFrame({ refId: 'A', fields: [{ name: 'AAA' }], meta: {} }), + toDataFrame({ refId: 'B', fields: [{ name: 'B111' }], meta: {} }), + ], + timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-1d', to: 'now' } }, + }; + + const panelDataB = filterPanelDataToQuery(loadingData, 'B'); + expect(panelDataB?.state).toBe(LoadingState.Loading); + + const panelDataA = filterPanelDataToQuery(loadingData, 'A'); + expect(panelDataA?.state).toBe(LoadingState.Loading); }); }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 5dab18c9886..fb7a32444c3 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -480,6 +480,8 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat const error = data.error && data.error.refId === refId ? data.error : undefined; if (error) { state = LoadingState.Error; + } else if (!error && data.state === LoadingState.Error) { + state = LoadingState.Done; } const timeRange = data.timeRange; diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index 5786b0149e5..575569fc59c 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -11,8 +11,8 @@ import { PanelData, } from '@grafana/data'; import { QueryEditorRow } from './QueryEditorRow'; -import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; -import { getDataSourceSrv } from '@grafana/runtime'; +import { DragDropContext, DragStart, Droppable, DropResult } from 'react-beautiful-dnd'; +import { getDataSourceSrv, reportInteraction } from '@grafana/runtime'; interface Props { // The query configuration @@ -81,8 +81,18 @@ export class QueryEditorRows extends PureComponent { ); } + onDragStart = (result: DragStart) => { + const { queries, dsSettings } = this.props; + + reportInteraction('query_row_reorder_started', { + startIndex: result.source.index, + numberOfQueries: queries.length, + datasourceType: dsSettings.type, + }); + }; + onDragEnd = (result: DropResult) => { - const { queries, onQueriesChange } = this.props; + const { queries, onQueriesChange, dsSettings } = this.props; if (!result || !result.destination) { return; @@ -91,6 +101,12 @@ export class QueryEditorRows extends PureComponent { const startIndex = result.source.index; const endIndex = result.destination.index; if (startIndex === endIndex) { + reportInteraction('query_row_reorder_canceled', { + startIndex, + endIndex, + numberOfQueries: queries.length, + datasourceType: dsSettings.type, + }); return; } @@ -98,13 +114,20 @@ export class QueryEditorRows extends PureComponent { const [removed] = update.splice(startIndex, 1); update.splice(endIndex, 0, removed); onQueriesChange(update); + + reportInteraction('query_row_reorder_ended', { + startIndex, + endIndex, + numberOfQueries: queries.length, + datasourceType: dsSettings.type, + }); }; render() { const { dsSettings, data, queries, app, history, eventBus } = this.props; return ( - + {(provided) => { return ( diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts index daf7bc5dba1..8f6917004c9 100644 --- a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts @@ -67,7 +67,7 @@ function expectOnResults(args: { done(); } catch (err) { subscription.unsubscribe(); - done.fail(err); + done(err); } }, }); diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index e68011186a4..ba65d463d4f 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -69,7 +69,7 @@ function expectOnResults(args: { expectCallback(value); done(); } catch (err) { - done.fail(err); + done(err); } }, }); diff --git a/public/app/features/search/components/ActionRow.tsx b/public/app/features/search/components/ActionRow.tsx index 54b46fcdaa0..dc6d0af3294 100644 --- a/public/app/features/search/components/ActionRow.tsx +++ b/public/app/features/search/components/ActionRow.tsx @@ -17,7 +17,7 @@ const searchSrv = new SearchSrv(); interface Props { onLayoutChange: (layout: SearchLayout) => void; - onShowPreviewsChange: (event: ChangeEvent) => void; + setShowPreviews: (newValue: boolean) => void; onSortChange: (value: SelectableValue) => void; onStarredFilterChange?: (event: FormEvent) => void; onTagFilterChange: (tags: string[]) => void; @@ -29,7 +29,7 @@ interface Props { export const ActionRow: FC = ({ onLayoutChange, - onShowPreviewsChange, + setShowPreviews, onSortChange, onStarredFilterChange = () => {}, onTagFilterChange, @@ -56,7 +56,7 @@ export const ActionRow: FC = ({ label="Show previews" showLabel value={showPreviews} - onChange={onShowPreviewsChange} + onChange={(ev: ChangeEvent) => setShowPreviews(ev.target.checked)} transparent /> )} diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index 4287b45aa0e..1864459e715 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -7,6 +7,7 @@ import { useDashboardSearch } from '../hooks/useDashboardSearch'; import { SearchField } from './SearchField'; import { SearchResults } from './SearchResults'; import { ActionRow } from './ActionRow'; +import { PreviewsSystemRequirements } from './PreviewsSystemRequirements'; export interface Props { onCloseSearch: () => void; @@ -14,7 +15,7 @@ export interface Props { export const DashboardSearch: FC = memo(({ onCloseSearch }) => { const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery({}); - const { results, loading, onToggleSection, onKeyDown, showPreviews, onShowPreviewsChange } = useDashboardSearch( + const { results, loading, onToggleSection, onKeyDown, showPreviews, setShowPreviews } = useDashboardSearch( query, onCloseSearch ); @@ -34,13 +35,18 @@ export const DashboardSearch: FC = memo(({ onCloseSearch }) => { onShowPreviewsChange(ev.target.checked), + setShowPreviews, onSortChange, onTagFilterChange, query, showPreviews, }} /> + setShowPreviews(false)} + /> = memo(({ folder }) => { onMoveItems, noFolders, showPreviews, - onShowPreviewsChange, + setShowPreviews, } = useManageDashboards(query, {}, folder); const onMoveTo = () => { @@ -108,7 +108,7 @@ export const ManageDashboards: FC = memo(({ folder }) => { canMove={hasEditPermissionInFolders && canMove} deleteItem={onItemDelete} moveTo={onMoveTo} - onShowPreviewsChange={(ev) => onShowPreviewsChange(ev.target.checked)} + setShowPreviews={setShowPreviews} onToggleAllChecked={onToggleAllChecked} onStarredFilterChange={onStarredFilterChange} onSortChange={onSortChange} diff --git a/public/app/features/search/components/PreviewsSystemRequirements.tsx b/public/app/features/search/components/PreviewsSystemRequirements.tsx new file mode 100644 index 00000000000..0e023c4e138 --- /dev/null +++ b/public/app/features/search/components/PreviewsSystemRequirements.tsx @@ -0,0 +1,93 @@ +import { Alert, useStyles2 } from '@grafana/ui'; +import React from 'react'; +import { config } from '@grafana/runtime/src'; +import { css } from '@emotion/css'; + +export interface Props { + showPreviews?: boolean; + /** On click handler for alert button, mostly used for dismissing the alert */ + onRemove?: (event: React.MouseEvent) => void; + topSpacing?: number; + bottomSpacing?: number; +} + +const MessageLink = ({ text }: { text: string }) => ( + + {text} + +); + +const Message = ({ requiredImageRendererPluginVersion }: { requiredImageRendererPluginVersion?: string }) => { + if (requiredImageRendererPluginVersion) { + return ( + <> + You must update the to version{' '} + {requiredImageRendererPluginVersion} to enable dashboard previews. Please contact your Grafana administrator to + update the plugin. + + ); + } + + return ( + <> + You must install the to enable dashboard previews. Please + contact your Grafana administrator to install the plugin. + + ); +}; + +export const PreviewsSystemRequirements = ({ showPreviews, onRemove, topSpacing, bottomSpacing }: Props) => { + const styles = useStyles2(getStyles); + + const previewsEnabled = config.featureToggles.dashboardPreviews; + const rendererAvailable = config.rendererAvailable; + + const { + systemRequirements: { met: systemRequirementsMet, requiredImageRendererPluginVersion }, + thumbnailsExist, + } = config.dashboardPreviews; + + const arePreviewsEnabled = previewsEnabled && showPreviews; + const areRequirementsMet = (rendererAvailable && systemRequirementsMet) || thumbnailsExist; + const shouldDisplayRequirements = arePreviewsEnabled && !areRequirementsMet; + + const title = requiredImageRendererPluginVersion + ? 'Image renderer plugin needs to be updated' + : 'Image renderer plugin not installed'; + + return ( + <> + {shouldDisplayRequirements && ( +
+ + + +
+ )} + + ); +}; + +const getStyles = () => { + return { + wrapper: css` + display: flex; + justify-content: center; + `, + alert: css` + max-width: 800px; + `, + }; +}; diff --git a/public/app/features/search/components/SearchResultsFilter.test.tsx b/public/app/features/search/components/SearchResultsFilter.test.tsx index 798636be17d..ff587ac1ec7 100644 --- a/public/app/features/search/components/SearchResultsFilter.test.tsx +++ b/public/app/features/search/components/SearchResultsFilter.test.tsx @@ -37,7 +37,7 @@ const setup = (propOverrides?: Partial) => { onLayoutChange: noop, query: searchQuery, onSortChange: noop, - onShowPreviewsChange: noop, + setShowPreviews: noop, editable: true, }; diff --git a/public/app/features/search/components/SearchResultsFilter.tsx b/public/app/features/search/components/SearchResultsFilter.tsx index 70b7cd33e74..7a8b43b9ba7 100644 --- a/public/app/features/search/components/SearchResultsFilter.tsx +++ b/public/app/features/search/components/SearchResultsFilter.tsx @@ -1,9 +1,10 @@ -import React, { FC, ChangeEvent, FormEvent } from 'react'; +import React, { FC, FormEvent } from 'react'; import { css } from '@emotion/css'; import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { DashboardQuery, SearchLayout } from '../types'; import { ActionRow } from './ActionRow'; +import { PreviewsSystemRequirements } from './PreviewsSystemRequirements'; export interface Props { allChecked?: boolean; @@ -13,7 +14,7 @@ export interface Props { hideLayout?: boolean; moveTo: () => void; onLayoutChange: (layout: SearchLayout) => void; - onShowPreviewsChange: (event: ChangeEvent) => void; + setShowPreviews: (newValue: boolean) => void; onSortChange: (value: SelectableValue) => void; onStarredFilterChange: (event: FormEvent) => void; onTagFilterChange: (tags: string[]) => void; @@ -31,7 +32,7 @@ export const SearchResultsFilter: FC = ({ hideLayout, moveTo, onLayoutChange, - onShowPreviewsChange, + setShowPreviews, onSortChange, onStarredFilterChange, onTagFilterChange, @@ -46,35 +47,43 @@ export const SearchResultsFilter: FC = ({ return (
- {editable && ( -
- -
- )} - {showActions ? ( - - - - - ) : ( - - )} +
+ {editable && ( +
+ +
+ )} + {showActions ? ( + + + + + ) : ( + + )} +
+ setShowPreviews(false)} + />
); }; @@ -83,6 +92,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { const { sm, md } = theme.spacing; return { wrapper: css` + display: flex; + flex-direction: column; + `, + rowWrapper: css` height: ${theme.height.md}px; display: flex; justify-content: flex-start; diff --git a/public/app/features/search/hooks/useDashboardSearch.ts b/public/app/features/search/hooks/useDashboardSearch.ts index 6fbcb220ede..c3b7434f002 100644 --- a/public/app/features/search/hooks/useDashboardSearch.ts +++ b/public/app/features/search/hooks/useDashboardSearch.ts @@ -12,7 +12,7 @@ import { useDebounce } from 'react-use'; export const useDashboardSearch = (query: DashboardQuery, onCloseSearch: () => void) => { const reducer = useReducer(searchReducer, dashboardsSearchState); - const { showPreviews, onShowPreviewsChange, previewFeatureEnabled } = useShowDashboardPreviews(); + const { showPreviews, setShowPreviews, previewFeatureEnabled } = useShowDashboardPreviews(); const { state: { results, loading }, onToggleSection, @@ -72,6 +72,6 @@ export const useDashboardSearch = (query: DashboardQuery, onCloseSearch: () => v onToggleSection, onKeyDown, showPreviews, - onShowPreviewsChange, + setShowPreviews, }; }; diff --git a/public/app/features/search/hooks/useManageDashboards.ts b/public/app/features/search/hooks/useManageDashboards.ts index 12a5e61422c..caa971f5b8f 100644 --- a/public/app/features/search/hooks/useManageDashboards.ts +++ b/public/app/features/search/hooks/useManageDashboards.ts @@ -47,7 +47,7 @@ export const useManageDashboards = ( ...state, }); - const { showPreviews, onShowPreviewsChange, previewFeatureEnabled } = useShowDashboardPreviews(); + const { showPreviews, setShowPreviews, previewFeatureEnabled } = useShowDashboardPreviews(); useDebounce( () => { reportDashboardListViewed('manage_dashboards', showPreviews, previewFeatureEnabled, { @@ -123,6 +123,6 @@ export const useManageDashboards = ( onMoveItems, noFolders, showPreviews, - onShowPreviewsChange, + setShowPreviews, }; }; diff --git a/public/app/features/search/hooks/useShowDashboardPreviews.ts b/public/app/features/search/hooks/useShowDashboardPreviews.ts index 2a65e36b0a2..e15780fff6d 100644 --- a/public/app/features/search/hooks/useShowDashboardPreviews.ts +++ b/public/app/features/search/hooks/useShowDashboardPreviews.ts @@ -5,9 +5,6 @@ import { useLocalStorage } from 'react-use'; export const useShowDashboardPreviews = () => { const previewFeatureEnabled = Boolean(config.featureToggles.dashboardPreviews); const [showPreviews, setShowPreviews] = useLocalStorage(PREVIEWS_LOCAL_STORAGE_KEY, previewFeatureEnabled); - const onShowPreviewsChange = (showPreviews: boolean) => { - setShowPreviews(showPreviews); - }; - return { showPreviews: Boolean(showPreviews && previewFeatureEnabled), previewFeatureEnabled, onShowPreviewsChange }; + return { showPreviews: Boolean(showPreviews && previewFeatureEnabled), previewFeatureEnabled, setShowPreviews }; }; diff --git a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx b/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx index 23dcd972391..be500c1f26e 100644 --- a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react'; -import { DeleteButton, Icon, Tooltip, useTheme2 } from '@grafana/ui'; +import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; import { ApiKey } from '../../types'; @@ -22,25 +22,19 @@ export const ServiceAccountTokensTable: FC = ({ tokens, timeZone, onDelet Name Expires + Created {tokens.map((key) => { - const isExpired = !!(key.expiration && Date.now() > new Date(key.expiration).getTime()); return ( - + {key.name} - {formatDate(timeZone, key.expiration)} - {isExpired && ( - - - - - - )} + + {formatDate(timeZone, key.created)} onDelete(key)} /> @@ -60,11 +54,61 @@ function formatDate(timeZone: TimeZone, expiration?: string): string { return dateTimeFormat(expiration, { timeZone }); } +function formatSecondsLeftUntilExpiration(secondsUntilExpiration: number): string { + const days = Math.floor(secondsUntilExpiration / (3600 * 24)); + const daysFormat = days > 1 ? `${days} days` : `${days} day`; + return `Expires in ${daysFormat}`; +} + +interface TokenExpirationProps { + timeZone: TimeZone; + token: ApiKey; +} + +const TokenExpiration = ({ timeZone, token }: TokenExpirationProps) => { + const styles = useStyles2(getStyles); + if (!token.expiration) { + return Never; + } + if (token.secondsUntilExpiration) { + return ( + + {formatSecondsLeftUntilExpiration(token.secondsUntilExpiration)} + + ); + } + if (token.hasExpired) { + return ( + + Expired + + + + + + + ); + } + return {formatDate(timeZone, token.expiration)}; +}; + const getStyles = (theme: GrafanaTheme2) => ({ - tableRow: (isExpired: boolean) => css` - color: ${isExpired ? theme.colors.text.secondary : theme.colors.text.primary}; + tableRow: (hasExpired: boolean | undefined) => css` + color: ${hasExpired ? theme.colors.text.secondary : theme.colors.text.primary}; `, tooltipContainer: css` margin-left: ${theme.spacing(1)}; `, + toolTipIcon: css` + color: ${theme.colors.error.text}; + `, + secondsUntilExpiration: css` + color: ${theme.colors.warning.text}; + `, + hasExpired: css` + color: ${theme.colors.error.text}; + `, + neverExpire: css` + color: ${theme.colors.text.secondary}; + `, }); diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index ebe981efc8f..c99d43b1a9f 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -9,6 +9,8 @@ import { FormatRegistryID } from './formatRegistry'; import { setDataSourceSrv } from '@grafana/runtime'; import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +const key = 'key'; + variableAdapters.setInit(() => [ createQueryVariableAdapter() as unknown as VariableAdapter, createAdHocVariableAdapter() as unknown as VariableAdapter, @@ -20,7 +22,7 @@ describe('templateSrv', () => { describe('init', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should initialize template data', () => { @@ -31,7 +33,7 @@ describe('templateSrv', () => { describe('replace can pass scoped vars', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('scoped vars should support objects', () => { @@ -115,7 +117,7 @@ describe('templateSrv', () => { describe('getAdhocFilters', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'datasource', name: 'ds', @@ -152,7 +154,7 @@ describe('templateSrv', () => { describe('replace can pass multi / all format', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -168,7 +170,7 @@ describe('templateSrv', () => { describe('when the globbed variable only has one value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -216,7 +218,7 @@ describe('templateSrv', () => { describe('variable with all option', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -254,7 +256,7 @@ describe('templateSrv', () => { describe('variable with all option and custom value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -298,19 +300,19 @@ describe('templateSrv', () => { describe('lucene format', () => { it('should properly escape $test with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:$test', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); it('should properly escape ${test} with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:${test}', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); it('should properly escape ${test:lucene} with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:${test:lucene}', {}); expect(target).toBe('this:value\\/4'); }); @@ -318,7 +320,7 @@ describe('templateSrv', () => { describe('html format', () => { it('should encode values html escape sequences', () => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', current: { value: '' } }, ]); const target = _templateSrv.replace('$test', {}, 'html'); @@ -426,7 +428,7 @@ describe('templateSrv', () => { describe('can check if variable exists', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should return true if $test exists', () => { @@ -467,7 +469,7 @@ describe('templateSrv', () => { describe('can highlight variables in string', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should insert html', () => { @@ -488,7 +490,7 @@ describe('templateSrv', () => { describe('updateIndex with simple value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'muuuu' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'muuuu' } }]); }); it('should set current value and update template data', () => { @@ -499,7 +501,7 @@ describe('templateSrv', () => { describe('replaceWithText', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'server', @@ -544,7 +546,7 @@ describe('templateSrv', () => { describe('replaceWithText can pass all / multi value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'server', @@ -595,7 +597,7 @@ describe('templateSrv', () => { describe('built in interval variables', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([]); + _templateSrv = initTemplateSrv(key, []); }); it('should replace $__interval_ms with interval milliseconds', () => { @@ -608,7 +610,7 @@ describe('templateSrv', () => { describe('date formating', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([], { + _templateSrv = initTemplateSrv(key, [], { from: dateTime(1594671549254), to: dateTime(1595237229747), } as TimeRange); @@ -642,7 +644,7 @@ describe('templateSrv', () => { describe('handle objects gracefully', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: { test: 'A' } } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: { test: 'A' } } }]); }); it('should not pass object to custom function', () => { @@ -658,7 +660,7 @@ describe('templateSrv', () => { describe('handle objects gracefully and call toString if defined', () => { beforeEach(() => { const value = { test: 'A', toString: () => 'hello' }; - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value } }]); }); it('should not pass object to custom function', () => { @@ -673,7 +675,7 @@ describe('templateSrv', () => { describe('adhoc variables', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'adhoc', name: 'adhoc', @@ -713,7 +715,7 @@ describe('templateSrv', () => { describe('queryparam', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'single', diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/types.ts similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/types.ts diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/utils.ts diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts diff --git a/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx b/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx new file mode 100644 index 00000000000..dc0e6eb1fed --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx @@ -0,0 +1,49 @@ +import React, { useEffect } from 'react'; +import { + PanelOptionsEditorBuilder, + PluginState, + StandardEditorContext, + TransformerRegistryItem, + TransformerUIProps, +} from '@grafana/data'; + +import { HeatmapTransformerOptions, heatmapTransformer } from './heatmap'; +import { addHeatmapCalculationOptions } from './editor/helper'; +import { getDefaultOptions, getTransformerOptionPane } from '../spatial/optionsHelper'; + +// Nothing defined in state +const supplier = ( + builder: PanelOptionsEditorBuilder, + context: StandardEditorContext +) => { + const options = context.options ?? {}; + + addHeatmapCalculationOptions('', builder, options); +}; + +export const HeatmapTransformerEditor: React.FC> = (props) => { + useEffect(() => { + if (!props.options.xAxis?.mode) { + const opts = getDefaultOptions(supplier); + props.onChange({ ...opts, ...props.options }); + console.log('geometry useEffect', opts); + } + }); + + // Shared with spatial transformer + const pane = getTransformerOptionPane(props, supplier); + return ( +
+
{pane.items.map((v) => v.render())}
+
+ ); +}; + +export const heatmapTransformRegistryItem: TransformerRegistryItem = { + id: heatmapTransformer.id, + editor: HeatmapTransformerEditor, + transformation: heatmapTransformer, + name: heatmapTransformer.name, + description: heatmapTransformer.description, + state: PluginState.alpha, +}; diff --git a/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx new file mode 100644 index 00000000000..a35e9ba126c --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { SelectableValue, StandardEditorProps } from '@grafana/data'; +import { HorizontalGroup, Input, RadioButtonGroup } from '@grafana/ui'; +import { HeatmapCalculationAxisConfig, HeatmapCalculationMode } from '../models.gen'; + +const modeOptions: Array> = [ + { + label: 'Size', + value: HeatmapCalculationMode.Size, + description: 'Split the buckets based on size', + }, + { + label: 'Count', + value: HeatmapCalculationMode.Count, + description: 'Split the buckets based on count', + }, +]; + +export const AxisEditor: React.FC> = ({ + value, + onChange, + item, +}) => { + return ( + + { + onChange({ + ...value, + mode, + }); + }} + /> + { + onChange({ + ...value, + value: v.currentTarget.value, + }); + }} + /> + + ); +}; diff --git a/public/app/features/transformers/calculateHeatmap/editor/helper.ts b/public/app/features/transformers/calculateHeatmap/editor/helper.ts new file mode 100644 index 00000000000..5ba5721f43d --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/editor/helper.ts @@ -0,0 +1,33 @@ +import { PanelOptionsEditorBuilder } from '@grafana/data'; + +import { HeatmapCalculationMode, HeatmapCalculationOptions } from '../models.gen'; +import { AxisEditor } from './AxisEditor'; + +export function addHeatmapCalculationOptions( + prefix: string, + builder: PanelOptionsEditorBuilder, + source?: HeatmapCalculationOptions, + category?: string[] +) { + builder.addCustomEditor({ + id: 'xAxis', + path: `${prefix}xAxis`, + name: 'X Buckets', + editor: AxisEditor, + category, + defaultValue: { + mode: HeatmapCalculationMode.Size, + }, + }); + + builder.addCustomEditor({ + id: 'yAxis', + path: `${prefix}yAxis`, + name: 'Y Buckets', + editor: AxisEditor, + category, + defaultValue: { + mode: HeatmapCalculationMode.Size, + }, + }); +} diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts new file mode 100644 index 00000000000..58303f46cf5 --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts @@ -0,0 +1,23 @@ +import { FieldType } from '@grafana/data'; +import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame'; +import { calculateHeatmapFromData } from './heatmap'; +import { HeatmapCalculationOptions } from './models.gen'; + +describe('Heatmap transformer', () => { + it('calculate heatmap from input data', async () => { + const options: HeatmapCalculationOptions = { + // + }; + + const data = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3, 4] }, + { name: 'temp', type: FieldType.number, values: [1.1, 2.2, 3.3, 4.4] }, + ], + }); + + const heatmap = calculateHeatmapFromData([data], options); + + expect(heatmap).toBeDefined(); + }); +}); diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts new file mode 100644 index 00000000000..1ca19f436ec --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -0,0 +1,372 @@ +import { + ArrayVector, + DataFrame, + DataTransformerID, + FieldType, + incrRoundUp, + incrRoundDn, + SynchronousDataTransformerInfo, + DataFrameType, + getFieldDisplayName, + Field, +} from '@grafana/data'; +import { map } from 'rxjs'; +import { HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen'; +import { niceLinearIncrs, niceTimeIncrs } from './utils'; + +export interface HeatmapTransformerOptions extends HeatmapCalculationOptions { + /** the raw values will still exist in results after transformation */ + keepOriginalData?: boolean; +} + +export const heatmapTransformer: SynchronousDataTransformerInfo = { + id: DataTransformerID.heatmap, + name: 'Create heatmap', + description: 'calculate heatmap from source data', + defaultOptions: {}, + + operator: (options) => (source) => source.pipe(map((data) => heatmapTransformer.transformer(options)(data))), + + transformer: (options: HeatmapTransformerOptions) => { + return (data: DataFrame[]) => { + const v = calculateHeatmapFromData(data, options); + if (options.keepOriginalData) { + return [v, ...data]; + } + return [v]; + }; + }, +}; + +export function sortAscStrInf(aName?: string | null, bName?: string | null) { + let aBound = aName === '+Inf' ? Infinity : +(aName ?? 0); + let bBound = bName === '+Inf' ? Infinity : +(bName ?? 0); + + return aBound - bBound; +} + +/** Given existing buckets, create a values style frame */ +export function createHeatmapFromBuckets(frames: DataFrame[]): DataFrame { + frames = frames.slice(); + + // sort ASC by frame.name (Prometheus bucket bound) + // or use frame.fields[1].config.displayNameFromDS ? + frames.sort((a, b) => sortAscStrInf(a.name, b.name)); + + const bucketBounds = frames.map((frame, i) => { + return i; // until we have y ordinal scales working for facets/scatter + + /* + let bound: number; + + if (frame.name === '+Inf') { + // TODO: until we have labeled y, treat +Inf as previous bucket + 10% + bound = +(frames[i - 1].name ?? 0) * 1.1; + } else { + bound = +(frame.name ?? 0); + } + + return bound; + */ + }); + + // assumes all Time fields are identical + // TODO: handle null-filling w/ fields[0].config.interval? + const xField = frames[0].fields[0]; + const xValues = xField.values.toArray(); + const yField = frames[0].fields[1]; + + // similar to initBins() below + const len = xValues.length * bucketBounds.length; + const xs = new Array(len); + const ys = new Array(len); + const counts2 = new Array(len); + + // cumulative counts + const counts = frames.map((frame) => frame.fields[1].values.toArray().slice()); + + // de-accumulate + counts.reverse(); + counts.forEach((bucketCounts, bi) => { + if (bi < counts.length - 1) { + for (let i = 0; i < bucketCounts.length; i++) { + bucketCounts[i] -= counts[bi + 1][i]; + } + } + }); + counts.reverse(); + + // transpose + counts.forEach((bucketCounts, bi) => { + for (let i = 0; i < bucketCounts.length; i++) { + counts2[counts.length * i + bi] = bucketCounts[i]; + } + }); + + // fill flat/repeating array + for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) { + ys[i] = bucketBounds[yi]; + + if (yi === 0 && i >= bucketBounds.length) { + xi++; + } + + xs[i] = xValues[xi]; + } + + return { + length: xs.length, + meta: { + type: DataFrameType.HeatmapScanlines, + }, + fields: [ + { + name: 'xMax', + type: xField.type, + values: new ArrayVector(xs), + config: xField.config, + }, + { + name: 'yMax', + type: FieldType.number, + values: new ArrayVector(ys), + config: yField.config, + }, + { + name: 'count', + type: FieldType.number, + values: new ArrayVector(counts2), + config: { + unit: 'short', + }, + }, + ], + }; +} + +export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame { + //console.time('calculateHeatmapFromData'); + + let xs: number[] = []; + let ys: number[] = []; + + // optimization + //let xMin = Infinity; + //let xMax = -Infinity; + + let xField: Field | undefined = undefined; + let yField: Field | undefined = undefined; + + for (let frame of frames) { + // TODO: assumes numeric timestamps, ordered asc, without nulls + const x = frame.fields.find((f) => f.type === FieldType.time); + if (!x) { + continue; + } + + if (!xField) { + xField = x; // the first X + } + + const xValues = x.values.toArray(); + for (let field of frame.fields) { + if (field !== x && field.type === FieldType.number) { + xs = xs.concat(xValues); + ys = ys.concat(field.values.toArray()); + + if (!yField) { + yField = field; + } + } + } + } + + if (!xField || !yField) { + throw 'no heatmap fields found'; + } + + const heat2d = heatmap(xs, ys, { + xSorted: true, + xTime: xField.type === FieldType.time, + xMode: options.xAxis?.mode, + xSize: +(options.xAxis?.value ?? 0), + yMode: options.yAxis?.mode, + ySize: +(options.yAxis?.value ?? 0), + }); + + const frame = { + length: heat2d.x.length, + name: getFieldDisplayName(yField), + meta: { + type: DataFrameType.HeatmapScanlines, + }, + fields: [ + { + name: 'xMin', + type: xField.type, + values: new ArrayVector(heat2d.x), + config: xField.config, + }, + { + name: 'yMin', + type: FieldType.number, + values: new ArrayVector(heat2d.y), + config: yField.config, // keep units from the original source + }, + { + name: 'count', + type: FieldType.number, + values: new ArrayVector(heat2d.count), + config: {}, + }, + ], + }; + + //console.timeEnd('calculateHeatmapFromData'); + + //console.log({ tiles: frame.length }); + + return frame; +} + +interface HeatmapOpts { + // default is 10% of data range, snapped to a "nice" increment + xMode?: HeatmapCalculationMode; + yMode?: HeatmapCalculationMode; + xSize?: number; + ySize?: number; + + // use Math.ceil instead of Math.floor for bucketing + xCeil?: boolean; + yCeil?: boolean; + + // log2 or log10 buckets + xLog?: 2 | 10; + yLog?: 2 | 10; + + xTime?: boolean; + yTime?: boolean; + + // optimization hints for known data ranges (sorted, pre-scanned, etc) + xMin?: number; + xMax?: number; + yMin?: number; + yMax?: number; + + xSorted?: boolean; + ySorted?: boolean; +} + +// TODO: handle NaN, Inf, -Inf, null, undefined values in xs & ys +function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) { + let len = xs.length; + + let xSorted = opts?.xSorted ?? false; + let ySorted = opts?.ySorted ?? false; + + // find x and y limits to pre-compute buckets struct + let minX = xSorted ? xs[0] : Infinity; + let minY = ySorted ? ys[0] : Infinity; + let maxX = xSorted ? xs[len - 1] : -Infinity; + let maxY = ySorted ? ys[len - 1] : -Infinity; + + for (let i = 0; i < len; i++) { + if (!xSorted) { + minX = Math.min(minX, xs[i]); + maxX = Math.max(maxX, xs[i]); + } + + if (!ySorted) { + minY = Math.min(minY, ys[i]); + maxY = Math.max(maxY, ys[i]); + } + } + + //let scaleX = opts?.xLog === 10 ? Math.log10 : opts?.xLog === 2 ? Math.log2 : (v: number) => v; + //let scaleY = opts?.yLog === 10 ? Math.log10 : opts?.yLog === 2 ? Math.log2 : (v: number) => v; + + let xBinIncr = opts?.xSize ?? 0; + let yBinIncr = opts?.ySize ?? 0; + let xMode = opts?.xMode; + let yMode = opts?.yMode; + + // fall back to 10 buckets if invalid settings + if (!Number.isFinite(xBinIncr) || xBinIncr <= 0) { + xMode = HeatmapCalculationMode.Count; + xBinIncr = 20; + } + if (!Number.isFinite(yBinIncr) || yBinIncr <= 0) { + yMode = HeatmapCalculationMode.Count; + yBinIncr = 10; + } + + if (xMode === HeatmapCalculationMode.Count) { + // TODO: optionally use view range min/max instead of data range for bucket sizing + let approx = (maxX - minX) / Math.max(xBinIncr - 1, 1); + // nice-ify + let xIncrs = opts?.xTime ? niceTimeIncrs : niceLinearIncrs; + let xIncrIdx = xIncrs.findIndex((bucketSize) => bucketSize > approx) - 1; + xBinIncr = xIncrs[Math.max(xIncrIdx, 0)]; + } + + if (yMode === HeatmapCalculationMode.Count) { + // TODO: optionally use view range min/max instead of data range for bucket sizing + let approx = (maxY - minY) / Math.max(yBinIncr - 1, 1); + // nice-ify + let yIncrs = opts?.yTime ? niceTimeIncrs : niceLinearIncrs; + let yIncrIdx = yIncrs.findIndex((bucketSize) => bucketSize > approx) - 1; + yBinIncr = yIncrs[Math.max(yIncrIdx, 0)]; + } + + // console.log({ + // yBinIncr, + // xBinIncr, + // }); + + let binX = opts?.xCeil ? (v: number) => incrRoundUp(v, xBinIncr) : (v: number) => incrRoundDn(v, xBinIncr); + let binY = opts?.yCeil ? (v: number) => incrRoundUp(v, yBinIncr) : (v: number) => incrRoundDn(v, yBinIncr); + + let minXBin = binX(minX); + let maxXBin = binX(maxX); + let minYBin = binY(minY); + let maxYBin = binY(maxY); + + let xBinQty = Math.round((maxXBin - minXBin) / xBinIncr) + 1; + let yBinQty = Math.round((maxYBin - minYBin) / yBinIncr) + 1; + + let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr); + + for (let i = 0; i < len; i++) { + const xi = (binX(xs[i]) - minXBin) / xBinIncr; + const yi = (binY(ys[i]) - minYBin) / yBinIncr; + const ci = xi * yBinQty + yi; + + counts[ci]++; + } + + return { + x: xs2, + y: ys2, + count: counts, + }; +} + +function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number) { + const len = xQty * yQty; + const xs = new Array(len); + const ys = new Array(len); + const counts = new Array(len); + + for (let i = 0, yi = 0, x = xMin; i < len; yi = ++i % yQty) { + counts[i] = 0; + ys[i] = yMin + yi * yIncr; + + if (yi === 0 && i >= yQty) { + x += xIncr; + } + + xs[i] = x; + } + + return [xs, ys, counts]; +} diff --git a/public/app/features/transformers/calculateHeatmap/models.gen.ts b/public/app/features/transformers/calculateHeatmap/models.gen.ts new file mode 100644 index 00000000000..eda086f883b --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/models.gen.ts @@ -0,0 +1,18 @@ +import { DataFrameType } from '@grafana/data'; + +export enum HeatmapCalculationMode { + Size = 'size', + Count = 'count', +} + +export interface HeatmapCalculationAxisConfig { + mode?: HeatmapCalculationMode; + value?: string; // number or interval string ie 10s +} + +export interface HeatmapCalculationOptions { + xAxis?: HeatmapCalculationAxisConfig; + yAxis?: HeatmapCalculationAxisConfig; + xAxisField?: string; // name of the x field + encoding?: DataFrameType.HeatmapBuckets | DataFrameType.HeatmapScanlines; +} diff --git a/public/app/features/transformers/calculateHeatmap/utils.ts b/public/app/features/transformers/calculateHeatmap/utils.ts new file mode 100644 index 00000000000..3851395f197 --- /dev/null +++ b/public/app/features/transformers/calculateHeatmap/utils.ts @@ -0,0 +1,125 @@ +const { abs, round, pow } = Math; + +export function roundDec(val: number, dec: number) { + return round(val * (dec = 10 ** dec)) / dec; +} + +export const fixedDec = new Map(); + +export function guessDec(num: number) { + return (('' + num).split('.')[1] || '').length; +} + +export function genIncrs(base: number, minExp: number, maxExp: number, mults: number[]) { + let incrs = []; + + let multDec = mults.map(guessDec); + + for (let exp = minExp; exp < maxExp; exp++) { + let expa = abs(exp); + let mag = roundDec(pow(base, exp), expa); + + for (let i = 0; i < mults.length; i++) { + let _incr = mults[i] * mag; + let dec = (_incr >= 0 && exp >= 0 ? 0 : expa) + (exp >= multDec[i] ? 0 : multDec[i]); + let incr = roundDec(_incr, dec); + incrs.push(incr); + fixedDec.set(incr, dec); + } + } + + return incrs; +} + +const onlyWhole = (v: number) => v % 1 === 0; + +const allMults = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5]; + +// ...0.01, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5... +export const decIncrs = genIncrs(10, -16, 0, allMults); + +// 1, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 20, 25, 30, 40, 50... +export const oneIncrs = genIncrs(10, 0, 16, allMults); + +// 1, 2, 3, 4, 5, 10, 20, 25, 50... +export const wholeIncrs = oneIncrs.filter(onlyWhole); + +export const numIncrs = decIncrs.concat(oneIncrs); + +export const niceLinearIncrs = decIncrs.concat(wholeIncrs); + +const sec = 1 * 1e3; +const min = 60 * sec; +const hour = 60 * min; +const day = 24 * hour; +const year = 365 * day; + +// in milliseconds +export const niceTimeIncrs = [ + 1, + 2, + 4, + 5, + 10, + 20, + 25, + 40, + 50, + 100, + 200, + 250, + 400, + 500, + + sec, + 2 * sec, + 4 * sec, + 5 * sec, + 10 * sec, + 15 * sec, + 20 * sec, + 30 * sec, + + min, + 2 * min, + 4 * min, + 5 * min, + 10 * min, + 15 * min, + 20 * min, + 30 * min, + + hour, + 2 * hour, + 4 * hour, + 6 * hour, + 8 * hour, + 12 * hour, + 18 * hour, + + day, + 2 * day, + 3 * day, + 4 * day, + 5 * day, + 6 * day, + 7 * day, + 10 * day, + 15 * day, + 30 * day, + 45 * day, + 60 * day, + 90 * day, + 180 * day, + + year, + 2 * year, + 3 * year, + 4 * year, + 5 * year, + 6 * year, + 7 * year, + 8 * year, + 9 * year, + 10 * year, +]; diff --git a/public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx b/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx rename to public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx diff --git a/public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.tsx b/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.tsx rename to public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/configFromQuery/configFromQuery.test.ts b/public/app/features/transformers/configFromQuery/configFromQuery.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/configFromQuery.test.ts rename to public/app/features/transformers/configFromQuery/configFromQuery.test.ts diff --git a/public/app/core/components/TransformersUI/configFromQuery/configFromQuery.ts b/public/app/features/transformers/configFromQuery/configFromQuery.ts similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/configFromQuery.ts rename to public/app/features/transformers/configFromQuery/configFromQuery.ts diff --git a/public/app/core/components/TransformersUI/CalculateFieldTransformerEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/CalculateFieldTransformerEditor.tsx rename to public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/ConcatenateTransformerEditor.tsx b/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/ConcatenateTransformerEditor.tsx rename to public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/ConvertFieldTypeTransformerEditor.tsx rename to public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByNameTransformerEditor.tsx b/public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByNameTransformerEditor.tsx rename to public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByRefIdTransformerEditor.tsx b/public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByRefIdTransformerEditor.tsx rename to public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx similarity index 98% rename from public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx rename to public/app/features/transformers/editors/GroupByTransformerEditor.tsx index a85cc42e5fb..d6e6c372817 100644 --- a/public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx @@ -15,7 +15,7 @@ import { GroupByOperationID, GroupByTransformerOptions, } from '@grafana/data/src/transformations/transformers/groupBy'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; interface FieldProps { fieldName: string; diff --git a/public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx rename to public/app/features/transformers/editors/HistogramTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/LabelsToFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/LabelsToFieldsTransformerEditor.tsx rename to public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/MergeTransformerEditor.tsx b/public/app/features/transformers/editors/MergeTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/MergeTransformerEditor.tsx rename to public/app/features/transformers/editors/MergeTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx similarity index 99% rename from public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx rename to public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx index a178567d995..2f7c9a2e023 100644 --- a/public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx @@ -12,7 +12,7 @@ import { stylesFactory, useTheme, Input, IconButton, Icon, FieldValidationMessag import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize'; import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; interface OrganizeFieldsTransformerEditorProps extends TransformerUIProps {} diff --git a/public/app/core/components/TransformersUI/ReduceTransformerEditor.tsx b/public/app/features/transformers/editors/ReduceTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/ReduceTransformerEditor.tsx rename to public/app/features/transformers/editors/ReduceTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/RenameByRegexTransformer.tsx b/public/app/features/transformers/editors/RenameByRegexTransformer.tsx similarity index 100% rename from public/app/core/components/TransformersUI/RenameByRegexTransformer.tsx rename to public/app/features/transformers/editors/RenameByRegexTransformer.tsx diff --git a/public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/SeriesToFieldsTransformerEditor.tsx similarity index 96% rename from public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx rename to public/app/features/transformers/editors/SeriesToFieldsTransformerEditor.tsx index 4146cfb24d1..028c9c55d1a 100644 --- a/public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/editors/SeriesToFieldsTransformerEditor.tsx @@ -9,7 +9,7 @@ import { import { Select } from '@grafana/ui'; import { SeriesToColumnsOptions } from '@grafana/data/src/transformations/transformers/seriesToColumns'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; export const SeriesToFieldsTransformerEditor: React.FC> = ({ input, diff --git a/public/app/core/components/TransformersUI/SeriesToRowsTransformerEditor.tsx b/public/app/features/transformers/editors/SeriesToRowsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/SeriesToRowsTransformerEditor.tsx rename to public/app/features/transformers/editors/SeriesToRowsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/SortByTransformerEditor.tsx b/public/app/features/transformers/editors/SortByTransformerEditor.tsx similarity index 97% rename from public/app/core/components/TransformersUI/SortByTransformerEditor.tsx rename to public/app/features/transformers/editors/SortByTransformerEditor.tsx index d23978972d2..51c931fc1b0 100644 --- a/public/app/core/components/TransformersUI/SortByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/SortByTransformerEditor.tsx @@ -2,7 +2,7 @@ import React, { useCallback } from 'react'; import { DataTransformerID, standardTransformers, TransformerRegistryItem, TransformerUIProps } from '@grafana/data'; import { InlineField, InlineSwitch, InlineFieldRow, Select } from '@grafana/ui'; import { SortByField, SortByTransformerOptions } from '@grafana/data/src/transformations/transformers/sortBy'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; export const SortByTransformerEditor: React.FC> = ({ input, diff --git a/public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx rename to public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/extractFields/extractFields.test.ts b/public/app/features/transformers/extractFields/extractFields.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/extractFields.test.ts rename to public/app/features/transformers/extractFields/extractFields.test.ts diff --git a/public/app/core/components/TransformersUI/extractFields/extractFields.ts b/public/app/features/transformers/extractFields/extractFields.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/extractFields.ts rename to public/app/features/transformers/extractFields/extractFields.ts diff --git a/public/app/core/components/TransformersUI/extractFields/fieldExtractor.test.ts b/public/app/features/transformers/extractFields/fieldExtractor.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/fieldExtractor.test.ts rename to public/app/features/transformers/extractFields/fieldExtractor.test.ts diff --git a/public/app/core/components/TransformersUI/extractFields/fieldExtractors.ts b/public/app/features/transformers/extractFields/fieldExtractors.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/fieldExtractors.ts rename to public/app/features/transformers/extractFields/fieldExtractors.ts diff --git a/public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx b/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx similarity index 100% rename from public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx rename to public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx diff --git a/public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.tsx b/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.tsx rename to public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx diff --git a/public/app/core/components/TransformersUI/fieldToConfigMapping/fieldToConfigMapping.ts b/public/app/features/transformers/fieldToConfigMapping/fieldToConfigMapping.ts similarity index 100% rename from public/app/core/components/TransformersUI/fieldToConfigMapping/fieldToConfigMapping.ts rename to public/app/features/transformers/fieldToConfigMapping/fieldToConfigMapping.ts diff --git a/public/app/core/components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor.tsx b/public/app/features/transformers/lookupGazetteer/FieldLookupTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor.tsx rename to public/app/features/transformers/lookupGazetteer/FieldLookupTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.test.ts b/public/app/features/transformers/lookupGazetteer/fieldLookup.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.test.ts rename to public/app/features/transformers/lookupGazetteer/fieldLookup.test.ts diff --git a/public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.ts b/public/app/features/transformers/lookupGazetteer/fieldLookup.ts similarity index 100% rename from public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.ts rename to public/app/features/transformers/lookupGazetteer/fieldLookup.ts diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx b/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx rename to public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts b/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts rename to public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts b/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.ts similarity index 100% rename from public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts rename to public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.ts diff --git a/public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.test.tsx b/public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.test.tsx similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.test.tsx rename to public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.test.tsx diff --git a/public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.tsx b/public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.tsx rename to public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/rowsToFields/rowsToFields.test.ts b/public/app/features/transformers/rowsToFields/rowsToFields.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/rowsToFields.test.ts rename to public/app/features/transformers/rowsToFields/rowsToFields.test.ts diff --git a/public/app/core/components/TransformersUI/rowsToFields/rowsToFields.ts b/public/app/features/transformers/rowsToFields/rowsToFields.ts similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/rowsToFields.ts rename to public/app/features/transformers/rowsToFields/rowsToFields.ts diff --git a/public/app/core/components/TransformersUI/spatial/SpatialTransformerEditor.tsx b/public/app/features/transformers/spatial/SpatialTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/spatial/SpatialTransformerEditor.tsx rename to public/app/features/transformers/spatial/SpatialTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/spatial/models.gen.ts b/public/app/features/transformers/spatial/models.gen.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/models.gen.ts rename to public/app/features/transformers/spatial/models.gen.ts diff --git a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx b/public/app/features/transformers/spatial/optionsHelper.tsx similarity index 95% rename from public/app/core/components/TransformersUI/spatial/optionsHelper.tsx rename to public/app/features/transformers/spatial/optionsHelper.tsx index 1d2ee131700..07ade5b3824 100644 --- a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx +++ b/public/app/features/transformers/spatial/optionsHelper.tsx @@ -5,10 +5,9 @@ import { NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; import { set, get as lodashGet } from 'lodash'; import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions'; -import { SpatialTransformOptions } from './models.gen'; export function getTransformerOptionPane( - props: TransformerUIProps, + props: TransformerUIProps, supplier: PanelOptionsSupplier ): OptionsPaneCategoryDescriptor { const context: StandardEditorContext = { diff --git a/public/app/core/components/TransformersUI/spatial/spatialTransformer.test.ts b/public/app/features/transformers/spatial/spatialTransformer.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/spatialTransformer.test.ts rename to public/app/features/transformers/spatial/spatialTransformer.test.ts diff --git a/public/app/core/components/TransformersUI/spatial/spatialTransformer.ts b/public/app/features/transformers/spatial/spatialTransformer.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/spatialTransformer.ts rename to public/app/features/transformers/spatial/spatialTransformer.ts diff --git a/public/app/core/components/TransformersUI/spatial/utils.ts b/public/app/features/transformers/spatial/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/utils.ts rename to public/app/features/transformers/spatial/utils.ts diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts new file mode 100644 index 00000000000..1d9792eb307 --- /dev/null +++ b/public/app/features/transformers/standardTransformers.ts @@ -0,0 +1,52 @@ +import { TransformerRegistryItem } from '@grafana/data'; +import { reduceTransformRegistryItem } from './editors/ReduceTransformerEditor'; +import { filterFieldsByNameTransformRegistryItem } from './editors/FilterByNameTransformerEditor'; +import { filterFramesByRefIdTransformRegistryItem } from './editors/FilterByRefIdTransformerEditor'; +import { filterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor'; +import { organizeFieldsTransformRegistryItem } from './editors/OrganizeFieldsTransformerEditor'; +import { seriesToFieldsTransformerRegistryItem } from './editors/SeriesToFieldsTransformerEditor'; +import { calculateFieldTransformRegistryItem } from './editors/CalculateFieldTransformerEditor'; +import { labelsToFieldsTransformerRegistryItem } from './editors/LabelsToFieldsTransformerEditor'; +import { groupByTransformRegistryItem } from './editors/GroupByTransformerEditor'; +import { sortByTransformRegistryItem } from './editors/SortByTransformerEditor'; +import { mergeTransformerRegistryItem } from './editors/MergeTransformerEditor'; +import { seriesToRowsTransformerRegistryItem } from './editors/SeriesToRowsTransformerEditor'; +import { concatenateTransformRegistryItem } from './editors/ConcatenateTransformerEditor'; +import { renameByRegexTransformRegistryItem } from './editors/RenameByRegexTransformer'; +import { histogramTransformRegistryItem } from './editors/HistogramTransformerEditor'; +import { rowsToFieldsTransformRegistryItem } from './rowsToFields/RowsToFieldsTransformerEditor'; +import { configFromQueryTransformRegistryItem } from './configFromQuery/ConfigFromQueryTransformerEditor'; +import { prepareTimeseriesTransformerRegistryItem } from './prepareTimeSeries/PrepareTimeSeriesEditor'; +import { convertFieldTypeTransformRegistryItem } from './editors/ConvertFieldTypeTransformerEditor'; +import { fieldLookupTransformRegistryItem } from './lookupGazetteer/FieldLookupTransformerEditor'; +import { extractFieldsTransformRegistryItem } from './extractFields/ExtractFieldsTransformerEditor'; +import { heatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor'; +import { spatialTransformRegistryItem } from './spatial/SpatialTransformerEditor'; + +export const getStandardTransformers = (): Array> => { + return [ + reduceTransformRegistryItem, + filterFieldsByNameTransformRegistryItem, + renameByRegexTransformRegistryItem, + filterFramesByRefIdTransformRegistryItem, + filterByValueTransformRegistryItem, + organizeFieldsTransformRegistryItem, + seriesToFieldsTransformerRegistryItem, + seriesToRowsTransformerRegistryItem, + concatenateTransformRegistryItem, + calculateFieldTransformRegistryItem, + labelsToFieldsTransformerRegistryItem, + groupByTransformRegistryItem, + sortByTransformRegistryItem, + mergeTransformerRegistryItem, + histogramTransformRegistryItem, + rowsToFieldsTransformRegistryItem, + configFromQueryTransformRegistryItem, + prepareTimeseriesTransformerRegistryItem, + convertFieldTypeTransformRegistryItem, + spatialTransformRegistryItem, + fieldLookupTransformRegistryItem, + extractFieldsTransformRegistryItem, + heatmapTransformRegistryItem, + ]; +}; diff --git a/public/app/core/components/TransformersUI/utils.ts b/public/app/features/transformers/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/utils.ts rename to public/app/features/transformers/utils.ts diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 7e505218622..9e4765490fd 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -1,7 +1,8 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import { setUsersSearchQuery } from './state/reducers'; -import { getInviteesCount, getUsersSearchQuery } from './state/selectors'; +import { selectTotal } from '../invites/state/selectors'; +import { getUsersSearchQuery } from './state/selectors'; import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types'; @@ -63,7 +64,7 @@ export class UsersActionBar extends PureComponent { function mapStateToProps(state: any) { return { searchQuery: getUsersSearchQuery(state.users), - pendingInvitesCount: getInviteesCount(state.users), + pendingInvitesCount: selectTotal(state.invites), externalUserMngLinkName: state.users.externalUserMngLinkName, externalUserMngLinkUrl: state.users.externalUserMngLinkUrl, canInvite: state.users.canInvite, diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index 396a5e66a00..4907b7403a6 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -26,7 +26,7 @@ const setup = (propOverrides?: object) => { searchQuery: '', searchPage: 1, externalUserMngInfo: '', - loadInvitees: jest.fn(), + fetchInvitees: jest.fn(), loadUsers: jest.fn(), updateUser: jest.fn(), removeUser: jest.fn(), diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 8f3dfd00fb7..1083b4f9ac1 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -6,20 +6,23 @@ import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import UsersActionBar from './UsersActionBar'; import UsersTable from './UsersTable'; -import InviteesTable from './InviteesTable'; +import InviteesTable from '../invites/InviteesTable'; import { OrgUser, OrgRole, StoreState } from 'app/types'; -import { loadInvitees, loadUsers, removeUser, updateUser } from './state/actions'; +import { loadUsers, removeUser, updateUser } from './state/actions'; +import { fetchInvitees } from '../invites/state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; -import { getInvitees, getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors'; +import { getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors'; import { setUsersSearchQuery, setUsersSearchPage } from './state/reducers'; +import { selectInvitesMatchingQuery } from '../invites/state/selectors'; function mapStateToProps(state: StoreState) { + const searchQuery = getUsersSearchQuery(state.users); return { navModel: getNavModel(state.navIndex, 'users'), users: getUsers(state.users), searchQuery: getUsersSearchQuery(state.users), searchPage: getUsersSearchPage(state.users), - invitees: getInvitees(state.users), + invitees: selectInvitesMatchingQuery(state.invites, searchQuery), externalUserMngInfo: state.users.externalUserMngInfo, hasFetched: state.users.hasFetched, }; @@ -27,7 +30,7 @@ function mapStateToProps(state: StoreState) { const mapDispatchToProps = { loadUsers, - loadInvitees, + fetchInvitees, setUsersSearchQuery, setUsersSearchPage, updateUser, @@ -69,7 +72,7 @@ export class UsersListPage extends PureComponent { } async fetchInvitees() { - return await this.props.loadInvitees(); + return await this.props.fetchInvitees(); } onRoleChange = (role: OrgRole, user: OrgUser) => { diff --git a/public/app/features/users/state/actions.ts b/public/app/features/users/state/actions.ts index cf07354f1b5..8d36742c584 100644 --- a/public/app/features/users/state/actions.ts +++ b/public/app/features/users/state/actions.ts @@ -1,8 +1,7 @@ -import { AccessControlAction, ThunkResult } from '../../../types'; +import { ThunkResult } from '../../../types'; import { getBackendSrv } from '@grafana/runtime'; import { OrgUser } from 'app/types'; -import { inviteesLoaded, usersLoaded } from './reducers'; -import { contextSrv } from 'app/core/core'; +import { usersLoaded } from './reducers'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; export function loadUsers(): ThunkResult { @@ -12,17 +11,6 @@ export function loadUsers(): ThunkResult { }; } -export function loadInvitees(): ThunkResult { - return async (dispatch) => { - if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) { - return; - } - - const invitees = await getBackendSrv().get('/api/org/invites'); - dispatch(inviteesLoaded(invitees)); - }; -} - export function updateUser(user: OrgUser): ThunkResult { return async (dispatch) => { await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role }); @@ -36,10 +24,3 @@ export function removeUser(userId: number): ThunkResult { dispatch(loadUsers()); }; } - -export function revokeInvite(code: string): ThunkResult { - return async (dispatch) => { - await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {}); - dispatch(loadInvitees()); - }; -} diff --git a/public/app/features/users/state/reducers.test.ts b/public/app/features/users/state/reducers.test.ts index 31912950686..6499959c449 100644 --- a/public/app/features/users/state/reducers.test.ts +++ b/public/app/features/users/state/reducers.test.ts @@ -1,7 +1,7 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { UsersState } from '../../../types'; -import { initialState, inviteesLoaded, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers'; -import { getMockInvitees, getMockUsers } from '../__mocks__/userMocks'; +import { initialState, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers'; +import { getMockUsers } from '../__mocks__/userMocks'; describe('usersReducer', () => { describe('when usersLoaded is dispatched', () => { @@ -17,19 +17,6 @@ describe('usersReducer', () => { }); }); - describe('when inviteesLoaded is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(usersReducer, { ...initialState }) - .whenActionIsDispatched(inviteesLoaded(getMockInvitees(1))) - .thenStateShouldEqual({ - ...initialState, - invitees: getMockInvitees(1), - hasFetched: true, - }); - }); - }); - describe('when setUsersSearchQuery is dispatched', () => { it('then state should be correct', () => { reducerTester() diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts index c43cbd17783..436feccdd78 100644 --- a/public/app/features/users/state/reducers.ts +++ b/public/app/features/users/state/reducers.ts @@ -1,10 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Invitee, OrgUser, UsersState } from 'app/types'; +import { OrgUser, UsersState } from 'app/types'; import config from 'app/core/config'; export const initialState: UsersState = { - invitees: [] as Invitee[], users: [] as OrgUser[], searchQuery: '', searchPage: 1, @@ -22,9 +21,6 @@ const usersSlice = createSlice({ usersLoaded: (state, action: PayloadAction): UsersState => { return { ...state, hasFetched: true, users: action.payload }; }, - inviteesLoaded: (state, action: PayloadAction): UsersState => { - return { ...state, hasFetched: true, invitees: action.payload }; - }, setUsersSearchQuery: (state, action: PayloadAction): UsersState => { // reset searchPage otherwise search results won't appear return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage }; @@ -35,7 +31,7 @@ const usersSlice = createSlice({ }, }); -export const { inviteesLoaded, setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions; +export const { setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions; export const usersReducer = usersSlice.reducer; diff --git a/public/app/features/users/state/selectors.ts b/public/app/features/users/state/selectors.ts index 361ff059c3e..19a200c69ff 100644 --- a/public/app/features/users/state/selectors.ts +++ b/public/app/features/users/state/selectors.ts @@ -8,14 +8,5 @@ export const getUsers = (state: UsersState) => { }); }; -export const getInvitees = (state: UsersState) => { - const regex = new RegExp(state.searchQuery, 'i'); - - return state.invitees.filter((invitee) => { - return regex.test(invitee.name) || regex.test(invitee.email); - }); -}; - -export const getInviteesCount = (state: UsersState) => state.invitees.length; export const getUsersSearchQuery = (state: UsersState) => state.searchQuery; export const getUsersSearchPage = (state: UsersState) => state.searchPage; diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx index 82944657f9e..2bb3a2d7289 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; - -import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; -import { initialAdHocVariableModelState } from './reducer'; import { selectOptionInTest } from '@grafana/ui'; import { getSelectParent } from '@grafana/ui/src/components/Select/test-utils'; +import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; +import { adHocBuilder } from '../shared/testing/builders'; + const props = { extended: { dataSources: [ @@ -13,7 +13,7 @@ const props = { { text: 'Loki', value: { type: 'loki-ds', uid: 'abc' } }, ], }, - variable: { ...initialAdHocVariableModelState }, + variable: adHocBuilder().withId('adhoc').withRootStateKey('key').withName('adhoc').build(), onPropChange: jest.fn(), // connected actions @@ -37,7 +37,10 @@ describe('AdHocVariableEditor', () => { render(); await selectOptionInTest(screen.getByLabelText('Data source'), 'Loki'); - expect(props.changeVariableDatasource).toBeCalledWith({ type: 'loki-ds', uid: 'abc' }); + expect(props.changeVariableDatasource).toBeCalledWith( + { type: 'adhoc', id: 'adhoc', rootStateKey: 'key' }, + { type: 'loki-ds', uid: 'abc' } + ); }); it('renders informational text', () => { diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx index f00a074c21d..3e4ca258479 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx @@ -5,15 +5,31 @@ import { DataSourceRef, SelectableValue } from '@grafana/data'; import { AdHocVariableModel } from '../types'; import { VariableEditorProps } from '../editor/types'; +import { initialVariableEditorState } from '../editor/reducer'; import { changeVariableDatasource, initAdHocVariableEditor } from './actions'; import { StoreState } from 'app/types'; import { VariableSectionHeader } from '../editor/VariableSectionHeader'; import { VariableSelectField } from '../editor/VariableSelectField'; import { getAdhocVariableEditorState } from '../editor/selectors'; +import { getVariablesState } from '../state/selectors'; +import { toKeyedVariableIdentifier } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - extended: getAdhocVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + + if (!rootStateKey) { + console.error('AdHocVariableEditor: variable has no rootStateKey'); + return { + extended: getAdhocVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + + return { + extended: getAdhocVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initAdHocVariableEditor, @@ -28,11 +44,17 @@ type Props = OwnProps & ConnectedProps; export class AdHocVariableEditorUnConnected extends PureComponent { componentDidMount() { - this.props.initAdHocVariableEditor(); + const { rootStateKey } = this.props.variable; + if (!rootStateKey) { + console.error('AdHocVariableEditor: variable has no rootStateKey'); + return; + } + + this.props.initAdHocVariableEditor(rootStateKey); } onDatasourceChanged = (option: SelectableValue) => { - this.props.changeVariableDatasource(option.value); + this.props.changeVariableDatasource(toKeyedVariableIdentifier(this.props.variable), option.value); }; render() { diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index 1a7d69c63c7..becf0bb4a76 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -3,8 +3,7 @@ import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data' import { variableAdapters } from '../adapters'; import { createAdHocVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from '../state/helpers'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; +import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers'; import { addFilter, AdHocTableOptions, @@ -21,6 +20,8 @@ import { VariableModel } from 'app/features/variables/types'; import { changeVariableEditorExtended, setIdInEditor } from '../editor/reducer'; import { adHocBuilder } from '../shared/testing/builders'; import { locationService } from '@grafana/runtime'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; const getList = jest.fn().mockReturnValue([]); const getDatasource = jest.fn().mockResolvedValue({}); @@ -55,6 +56,7 @@ const expectedDatasources = [ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and filter already exist', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -71,12 +73,13 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('Filters') + .withRootStateKey(key) .withName('Filters') .withFilters([existingFilter]) .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -84,7 +87,9 @@ describe('adhoc actions', () => { const expectedQuery = { 'var-Filters': ['filter-key|!=|filter-existing', 'filter-key|=|filter-value'] }; const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); @@ -92,6 +97,7 @@ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and previously no variable or filter exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -99,18 +105,23 @@ describe('adhoc actions', () => { operator: '=', }; - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource(options.datasource) + .build(); const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] }; const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; tester.thenDispatchedActionsShouldEqual( createAddVariableAction(variable), - filterAdded(toVariablePayload(variable, expectedFilter)) + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); @@ -119,6 +130,7 @@ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and previously no filter exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -128,12 +140,13 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('Filters') + .withRootStateKey(key) .withName('Filters') .withFilters([]) .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -141,13 +154,16 @@ describe('adhoc actions', () => { const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when applyFilterFromTable is dispatched and adhoc variable with other datasource exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -157,13 +173,19 @@ describe('adhoc actions', () => { const existing = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withDatasource({ uid: 'elasticsearch' }) .build(); - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource(options.datasource) + .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(existing)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -173,7 +195,7 @@ describe('adhoc actions', () => { tester.thenDispatchedActionsShouldEqual( createAddVariableAction(variable, 1), - filterAdded(toVariablePayload(variable, expectedFilter)) + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); @@ -182,6 +204,7 @@ describe('adhoc actions', () => { describe('when changeFilter is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -196,6 +219,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -206,12 +230,14 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(changeFilter('elastic-filter', update), true); + .whenAsyncActionIsDispatched(changeFilter(toKeyedVariableIdentifier(variable), update), true); const expectedQuery = { 'var-elastic-filter': ['key|!=|value'] }; const expectedUpdate = { index: 0, filter: updated }; - tester.thenDispatchedActionsShouldEqual(filterUpdated(toVariablePayload(variable, expectedUpdate))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterUpdated(toVariablePayload(variable, expectedUpdate))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); @@ -219,6 +245,7 @@ describe('adhoc actions', () => { describe('when addFilter is dispatched on variable with existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -233,6 +260,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -241,18 +269,21 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); + .whenAsyncActionIsDispatched(addFilter(toKeyedVariableIdentifier(variable), adding), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|!=|value'] }; const expectedFilter = { key: 'key', value: 'value', operator: '!=', condition: '' }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when addFilter is dispatched on variable with no existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const adding = { key: 'key', value: 'value', @@ -262,6 +293,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([]) .withDatasource({ uid: 'elasticsearch' }) @@ -270,19 +302,21 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); + .whenAsyncActionIsDispatched(addFilter(toKeyedVariableIdentifier(variable), adding), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value'] }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, adding))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterAdded(toVariablePayload(variable, adding)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when removeFilter is dispatched on variable with no existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([]) .withDatasource({ uid: 'elasticsearch' }) @@ -291,17 +325,18 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); + .whenAsyncActionIsDispatched(removeFilter(toKeyedVariableIdentifier(variable), 0), true); const expectedQuery = { 'var-elastic-filter': [] as string[] }; - tester.thenDispatchedActionsShouldEqual(filterRemoved(toVariablePayload(variable, 0))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterRemoved(toVariablePayload(variable, 0)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when removeFilter is dispatched on variable with existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const filter = { key: 'key', value: 'value', @@ -311,6 +346,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([filter]) .withDatasource({ uid: 'elasticsearch' }) @@ -319,17 +355,18 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); + .whenAsyncActionIsDispatched(removeFilter(toKeyedVariableIdentifier(variable), 0), true); const expectedQuery = { 'var-elastic-filter': [] as string[] }; - tester.thenDispatchedActionsShouldEqual(filterRemoved(toVariablePayload(variable, 0))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterRemoved(toVariablePayload(variable, 0)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when setFiltersFromUrl is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -339,6 +376,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -352,7 +390,7 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(setFiltersFromUrl('elastic-filter', fromUrl), true); + .whenAsyncActionIsDispatched(setFiltersFromUrl(toKeyedVariableIdentifier(variable), fromUrl), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|=|value'] }; const expectedFilters = [ @@ -360,28 +398,40 @@ describe('adhoc actions', () => { { key: 'key', value: 'value', operator: '=', condition: '', name: 'value-2' }, ]; - tester.thenDispatchedActionsShouldEqual(filtersRestored(toVariablePayload(variable, expectedFilters))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filtersRestored(toVariablePayload(variable, expectedFilters))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when initAdHocVariableEditor is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; + getList.mockRestore(); getList.mockReturnValue(datasources); const tester = reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(initAdHocVariableEditor()); + .whenActionIsDispatched(initAdHocVariableEditor(key)); - tester.thenDispatchedActionsShouldEqual(changeVariableEditorExtended({ dataSources: expectedDatasources })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, changeVariableEditorExtended({ dataSources: expectedDatasources })) + ); }); }); describe('when changeVariableDatasource is dispatched with unsupported datasource', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const datasource = { uid: 'mysql' }; - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource({ uid: 'influxdb' }).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource({ uid: 'influxdb' }) + .build(); getDatasource.mockRestore(); getDatasource.mockResolvedValue(null); @@ -391,25 +441,37 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenActionIsDispatched(initAdHocVariableEditor()) - .whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true); + .whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id }))) + .whenActionIsDispatched(initAdHocVariableEditor(key)) + .whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })), - changeVariableEditorExtended({ - infoText: 'This data source does not support ad hoc filters yet.', - dataSources: expectedDatasources, - }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ), + toKeyedAction( + key, + changeVariableEditorExtended({ + infoText: 'This data source does not support ad hoc filters yet.', + dataSources: expectedDatasources, + }) + ) ); }); }); describe('when changeVariableDatasource is dispatched with datasource', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const datasource = { uid: 'elasticsearch' }; const loadingText = 'Ad hoc filters are applied automatically to all queries that target this data source'; - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource({ uid: 'influxdb' }).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource({ uid: 'influxdb' }) + .build(); getDatasource.mockRestore(); getDatasource.mockResolvedValue({ @@ -421,23 +483,26 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenActionIsDispatched(initAdHocVariableEditor()) - .whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true); + .whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id }))) + .whenActionIsDispatched(initAdHocVariableEditor(key)) + .whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })), - changeVariableEditorExtended({ infoText: loadingText, dataSources: expectedDatasources }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ), + toKeyedAction(key, changeVariableEditorExtended({ infoText: loadingText, dataSources: expectedDatasources })) ); }); }); }); function createAddVariableAction(variable: VariableModel, index = 0) { - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); const global = false; const data = { global, index, model: { ...variable, index: -1, global } }; - return addVariable(toVariablePayload(identifier, data)); + return toKeyedAction(variable.rootStateKey!, addVariable(toVariablePayload(identifier, data))); } function createDatasource(name: string, selectable = true, isDefault = false): DataSourceInstanceSettings { diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts index e004c66f86e..b49e398e524 100644 --- a/public/app/features/variables/adhoc/actions.ts +++ b/public/app/features/variables/adhoc/actions.ts @@ -3,8 +3,8 @@ import { StoreState, ThunkResult } from 'app/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { changeVariableEditorExtended } from '../editor/reducer'; import { addVariable, changeVariableProp } from '../state/sharedReducer'; -import { getNewVariableIndex, getVariable } from '../state/selectors'; -import { AddVariable, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { getLastKey, getNewVariableIndex, getVariable, getVariablesState } from '../state/selectors'; +import { AddVariable, KeyedVariableIdentifier } from '../state/types'; import { AdHocVariabelFilterUpdate, filterAdded, @@ -18,6 +18,8 @@ import { variableUpdated } from '../state/actions'; import { isAdHoc } from '../guard'; import { DataSourceRef, getDataSourceRef } from '@grafana/data'; import { getAdhocVariableEditorState } from '../editor/selectors'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; export interface AdHocTableOptions { datasource: DataSourceRef; @@ -35,6 +37,9 @@ export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult f.key === options.key && f.value === options.value); @@ -42,52 +47,66 @@ export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult => { +export const changeFilter = ( + identifier: KeyedVariableIdentifier, + update: AdHocVariabelFilterUpdate +): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterUpdated(toVariablePayload(variable, update))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterUpdated(toVariablePayload(variable, update)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const removeFilter = (id: string, index: number): ThunkResult => { +export const removeFilter = (identifier: KeyedVariableIdentifier, index: number): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterRemoved(toVariablePayload(variable, index))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterRemoved(toVariablePayload(variable, index)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const addFilter = (id: string, filter: AdHocVariableFilter): ThunkResult => { +export const addFilter = (identifier: KeyedVariableIdentifier, filter: AdHocVariableFilter): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterAdded(toVariablePayload(variable, filter))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterAdded(toVariablePayload(variable, filter)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const setFiltersFromUrl = (id: string, filters: AdHocVariableFilter[]): ThunkResult => { +export const setFiltersFromUrl = ( + identifier: KeyedVariableIdentifier, + filters: AdHocVariableFilter[] +): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filtersRestored(toVariablePayload(variable, filters))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filtersRestored(toVariablePayload(variable, filters)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResult => { +export const changeVariableDatasource = ( + identifier: KeyedVariableIdentifier, + datasource?: DataSourceRef +): ThunkResult => { return async (dispatch, getState) => { - const { editor } = getState().templating; + const { editor } = getVariablesState(identifier.rootStateKey, getState()); const extended = getAdhocVariableEditorState(editor); - const variable = getVariable(editor.id, getState()); - dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource }))); + const variable = getVariable(identifier, getState()); + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ) + ); const ds = await getDatasourceSrv().get(datasource); @@ -97,57 +116,70 @@ export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResul : 'This data source does not support ad hoc filters yet.'; dispatch( - changeVariableEditorExtended({ - infoText: message, - dataSources: extended?.dataSources ?? [], - }) + toKeyedAction( + identifier.rootStateKey, + changeVariableEditorExtended({ + infoText: message, + dataSources: extended?.dataSources ?? [], + }) + ) ); }; }; -export const initAdHocVariableEditor = (): ThunkResult => (dispatch) => { - const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); - const selectable = dataSources.reduce( - (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { - if (ds.meta.mixed) { +export const initAdHocVariableEditor = + (key: string): ThunkResult => + (dispatch) => { + const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); + const selectable = dataSources.reduce( + (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { + if (ds.meta.mixed) { + return all; + } + + const text = ds.isDefault ? `${ds.name} (default)` : ds.name; + const value = getDataSourceRef(ds); + all.push({ text, value }); + return all; - } + }, + [{ text: '', value: {} }] + ); - const text = ds.isDefault ? `${ds.name} (default)` : ds.name; - const value = getDataSourceRef(ds); - all.push({ text, value }); - - return all; - }, - [{ text: '', value: {} }] - ); - - dispatch( - changeVariableEditorExtended({ - dataSources: selectable, - }) - ); -}; + dispatch( + toKeyedAction( + key, + changeVariableEditorExtended({ + dataSources: selectable, + }) + ) + ); + }; const createAdHocVariable = (options: AdHocTableOptions): ThunkResult => { return (dispatch, getState) => { - const model = { + const key = getLastKey(getState()); + + const model: AdHocVariableModel = { ...cloneDeep(initialAdHocVariableModelState), datasource: options.datasource, name: filterTableName, id: filterTableName, + rootStateKey: key, }; const global = false; - const index = getNewVariableIndex(getState()); - const identifier: VariableIdentifier = { type: 'adhoc', id: model.id }; + const index = getNewVariableIndex(key, getState()); + const identifier: KeyedVariableIdentifier = { type: 'adhoc', id: model.id, rootStateKey: key }; - dispatch(addVariable(toVariablePayload(identifier, { global, model, index }))); + dispatch(toKeyedAction(key, addVariable(toVariablePayload(identifier, { global, model, index })))); }; }; -const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel => { - return Object.values(state.templating.variables).find( +const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel | undefined => { + const key = getLastKey(state); + const templatingState = getVariablesState(key, state); + return Object.values(templatingState.variables).find( (v) => isAdHoc(v) && v.datasource?.uid === options.datasource.uid ) as AdHocVariableModel; }; diff --git a/public/app/features/variables/adhoc/adapter.ts b/public/app/features/variables/adhoc/adapter.ts index 10adfce70b7..6f2ed40e074 100644 --- a/public/app/features/variables/adhoc/adapter.ts +++ b/public/app/features/variables/adhoc/adapter.ts @@ -8,6 +8,7 @@ import { adHocVariableReducer, initialAdHocVariableModelState } from './reducer' import { AdHocVariableEditor } from './AdHocVariableEditor'; import { setFiltersFromUrl } from './actions'; import * as urlParser from './urlParser'; +import { toKeyedVariableIdentifier } from '../utils'; const noop = async () => {}; @@ -24,11 +25,11 @@ export const createAdHocVariableAdapter = (): VariableAdapter { const filters = urlParser.toFilters(urlValue); - await dispatch(setFiltersFromUrl(variable.id, filters)); + await dispatch(setFiltersFromUrl(toKeyedVariableIdentifier(variable), filters)); }, updateOptions: noop, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx index 8abb7a6fbf3..1fa6e55e1ce 100644 --- a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx +++ b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx @@ -4,6 +4,7 @@ import { AdHocVariableFilter, AdHocVariableModel } from 'app/features/variables/ import { VariablePickerProps } from '../../pickers/types'; import { addFilter, changeFilter, removeFilter } from '../actions'; import { AdHocFilter } from './AdHocFilter'; +import { toKeyedVariableIdentifier } from '../../utils'; const mapDispatchToProps = { addFilter, @@ -23,15 +24,15 @@ type Props = OwnProps & ConnectedProps; */ export class AdHocPickerUnconnected extends PureComponent { addFilter = (filter: AdHocVariableFilter) => { - this.props.addFilter(this.props.variable.id, filter); + this.props.addFilter(toKeyedVariableIdentifier(this.props.variable), filter); }; removeFilter = (index: number) => { - this.props.removeFilter(this.props.variable.id, index); + this.props.removeFilter(toKeyedVariableIdentifier(this.props.variable), index); }; changeFilter = (index: number, filter: AdHocVariableFilter) => { - this.props.changeFilter(this.props.variable.id, { + this.props.changeFilter(toKeyedVariableIdentifier(this.props.variable), { index, filter, }); diff --git a/public/app/features/variables/adhoc/reducer.test.ts b/public/app/features/variables/adhoc/reducer.test.ts index b3645e38b1a..ce4b7948cd4 100644 --- a/public/app/features/variables/adhoc/reducer.test.ts +++ b/public/app/features/variables/adhoc/reducer.test.ts @@ -1,10 +1,11 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { adHocVariableReducer, filterAdded, filterRemoved, filtersRestored, filterUpdated } from './reducer'; import { AdHocVariableFilter, AdHocVariableModel } from '../types'; import { createAdHocVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('adHocVariableReducer', () => { const adapter = createAdHocVariableAdapter(); diff --git a/public/app/features/variables/adhoc/reducer.ts b/public/app/features/variables/adhoc/reducer.ts index 3f5aa9f9222..f5ff0945961 100644 --- a/public/app/features/variables/adhoc/reducer.ts +++ b/public/app/features/variables/adhoc/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AdHocVariableFilter, AdHocVariableModel, initialVariableModelState } from 'app/features/variables/types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export interface AdHocVariabelFilterUpdate { index: number; diff --git a/public/app/features/variables/constant/actions.test.ts b/public/app/features/variables/constant/actions.test.ts index b483c8ad547..a609cdc69ce 100644 --- a/public/app/features/variables/constant/actions.test.ts +++ b/public/app/features/variables/constant/actions.test.ts @@ -4,9 +4,10 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { updateConstantVariableOptions } from './actions'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { ConstantVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { createConstantOptionsFromQuery } from './reducer'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('constant actions', () => { variableAdapters.setInit(() => [createConstantVariableAdapter()]); @@ -22,6 +23,7 @@ describe('constant actions', () => { const variable: ConstantVariableModel = { ...initialVariableModelState, id: '0', + rootStateKey: 'key', index: 0, type: 'constant', name: 'Constant', @@ -36,12 +38,14 @@ describe('constant actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateConstantVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateConstantVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createConstantOptionsFromQuery(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', createConstantOptionsFromQuery(toVariablePayload(variable))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); diff --git a/public/app/features/variables/constant/actions.ts b/public/app/features/variables/constant/actions.ts index db27b06739a..6f865bcaa19 100644 --- a/public/app/features/variables/constant/actions.ts +++ b/public/app/features/variables/constant/actions.ts @@ -1,11 +1,14 @@ import { validateVariableSelectionState } from '../state/actions'; import { ThunkResult } from 'app/types'; import { createConstantOptionsFromQuery } from './reducer'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; -export const updateConstantVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateConstantVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - await dispatch(createConstantOptionsFromQuery(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createConstantOptionsFromQuery(toVariablePayload(identifier)))); await dispatch(validateVariableSelectionState(identifier)); }; }; diff --git a/public/app/features/variables/constant/adapter.ts b/public/app/features/variables/constant/adapter.ts index 12aa0e85073..4555a63c788 100644 --- a/public/app/features/variables/constant/adapter.ts +++ b/public/app/features/variables/constant/adapter.ts @@ -6,8 +6,8 @@ import { VariableAdapter } from '../adapters'; import { constantVariableReducer, initialConstantVariableModelState } from './reducer'; import { ConstantVariableEditor } from './ConstantVariableEditor'; import { updateConstantVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; import { optionPickerFactory } from '../pickers'; +import { toKeyedVariableIdentifier } from '../utils'; export const createConstantVariableAdapter = (): VariableAdapter => { return { @@ -22,16 +22,16 @@ export const createConstantVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateConstantVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, current, options, ...rest } = cloneDeep(variable); + const { index, id, state, global, current, options, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/constant/reducer.test.ts b/public/app/features/variables/constant/reducer.test.ts index b6c937c52ea..932593582b2 100644 --- a/public/app/features/variables/constant/reducer.test.ts +++ b/public/app/features/variables/constant/reducer.test.ts @@ -1,10 +1,12 @@ -import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; + +import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { constantVariableReducer, createConstantOptionsFromQuery } from './reducer'; import { ConstantVariableModel } from '../types'; import { createConstantVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('constantVariableReducer', () => { const adapter = createConstantVariableAdapter(); diff --git a/public/app/features/variables/constant/reducer.ts b/public/app/features/variables/constant/reducer.ts index bf2f2d38be7..8a830d779d1 100644 --- a/public/app/features/variables/constant/reducer.ts +++ b/public/app/features/variables/constant/reducer.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { ConstantVariableModel, initialVariableModelState, VariableHide, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export const initialConstantVariableModelState: ConstantVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/custom/actions.test.ts b/public/app/features/variables/custom/actions.test.ts index c6508944b0d..9df0916264d 100644 --- a/public/app/features/variables/custom/actions.test.ts +++ b/public/app/features/variables/custom/actions.test.ts @@ -4,9 +4,10 @@ import { createCustomVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { createCustomOptionsFromQuery } from './reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('custom actions', () => { variableAdapters.setInit(() => [createCustomVariableAdapter()]); @@ -22,6 +23,7 @@ describe('custom actions', () => { const variable: CustomVariableModel = { ...initialVariableModelState, id: '0', + rootStateKey: 'key', index: 0, type: 'custom', name: 'Custom', @@ -49,12 +51,14 @@ describe('custom actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateCustomVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateCustomVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createCustomOptionsFromQuery(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', createCustomOptionsFromQuery(toVariablePayload(variable))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); diff --git a/public/app/features/variables/custom/actions.ts b/public/app/features/variables/custom/actions.ts index bc3ed3905a8..557d2044dca 100644 --- a/public/app/features/variables/custom/actions.ts +++ b/public/app/features/variables/custom/actions.ts @@ -1,11 +1,14 @@ import { validateVariableSelectionState } from '../state/actions'; import { ThunkResult } from 'app/types'; import { createCustomOptionsFromQuery } from './reducer'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; -export const updateCustomVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateCustomVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - await dispatch(createCustomOptionsFromQuery(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createCustomOptionsFromQuery(toVariablePayload(identifier)))); await dispatch(validateVariableSelectionState(identifier)); }; }; diff --git a/public/app/features/variables/custom/adapter.ts b/public/app/features/variables/custom/adapter.ts index b8aed7172cc..47af02927bc 100644 --- a/public/app/features/variables/custom/adapter.ts +++ b/public/app/features/variables/custom/adapter.ts @@ -6,8 +6,7 @@ import { VariableAdapter } from '../adapters'; import { customVariableReducer, initialCustomVariableModelState } from './reducer'; import { CustomVariableEditor } from './CustomVariableEditor'; import { updateCustomVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; -import { isAllVariable } from '../utils'; +import { isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -24,16 +23,16 @@ export const createCustomVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateCustomVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/custom/reducer.test.ts b/public/app/features/variables/custom/reducer.test.ts index 0cf66830900..39f0ef09815 100644 --- a/public/app/features/variables/custom/reducer.test.ts +++ b/public/app/features/variables/custom/reducer.test.ts @@ -1,11 +1,13 @@ -import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; + +import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createCustomOptionsFromQuery, customVariableReducer } from './reducer'; import { createCustomVariableAdapter } from './adapter'; import { CustomVariableModel } from '../types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toVariablePayload } from '../utils'; describe('customVariableReducer', () => { const adapter = createCustomVariableAdapter(); diff --git a/public/app/features/variables/custom/reducer.ts b/public/app/features/variables/custom/reducer.ts index a579f412f96..9a897030c30 100644 --- a/public/app/features/variables/custom/reducer.ts +++ b/public/app/features/variables/custom/reducer.ts @@ -1,8 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; export const initialCustomVariableModelState: CustomVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx index 0be82c77056..51f9a827f71 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx @@ -1,23 +1,38 @@ import React, { FormEvent, PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { SelectableValue } from '@grafana/data'; import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { DataSourceVariableModel, VariableWithMultiSupport } from '../types'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; +import { initialVariableEditorState } from '../editor/reducer'; import { initDataSourceVariableEditor } from './actions'; import { StoreState } from '../../../types'; import { changeVariableMultiValue } from '../state/actions'; import { VariableSectionHeader } from '../editor/VariableSectionHeader'; import { VariableSelectField } from '../editor/VariableSelectField'; -import { SelectableValue } from '@grafana/data'; import { VariableTextField } from '../editor/VariableTextField'; +import { getVariablesState } from '../state/selectors'; import { selectors } from '@grafana/e2e-selectors'; import { getDatasourceVariableEditorState } from '../editor/selectors'; -const mapStateToProps = (state: StoreState) => ({ - extended: getDatasourceVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { + variable: { rootStateKey }, + } = ownProps; + if (!rootStateKey) { + console.error('DataSourceVariableEditor: variable has no rootStateKey'); + return { + extended: getDatasourceVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + return { + extended: getDatasourceVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initDataSourceVariableEditor, @@ -32,7 +47,13 @@ type Props = OwnProps & ConnectedProps; export class DataSourceVariableEditorUnConnected extends PureComponent { componentDidMount() { - this.props.initDataSourceVariableEditor(); + const { rootStateKey } = this.props.variable; + if (!rootStateKey) { + console.error('DataSourceVariableEditor: variable has no rootStateKey'); + return; + } + + this.props.initDataSourceVariableEditor(rootStateKey); } onRegExChange = (event: FormEvent) => { diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index d8226da37ef..67f62788eae 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -2,7 +2,6 @@ import { DataSourceInstanceSettings } from '@grafana/data'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { getRootReducer, RootReducerType } from '../state/helpers'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { variableAdapters } from '../adapters'; import { createDataSourceVariableAdapter } from './adapter'; import { @@ -16,6 +15,8 @@ import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { changeVariableEditorExtended } from '../editor/reducer'; import { datasourceBuilder } from '../shared/testing/builders'; import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; interface Args { sources?: DataSourceInstanceSettings[]; @@ -27,7 +28,7 @@ function getTestContext({ sources = [], query, regex }: Args = {}) { const getListMock = jest.fn().mockReturnValue(sources); const getDatasourceSrvMock = jest.fn().mockReturnValue({ getList: getListMock }); const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock }; - const datasource = datasourceBuilder().withId('0').withQuery(query).withRegEx(regex).build(); + const datasource = datasourceBuilder().withId('0').withRootStateKey('key').withQuery(query).withRegEx(regex).build(); return { getListMock, getDatasourceSrvMock, dependencies, datasource }; } @@ -51,27 +52,36 @@ describe('data source actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + toKeyedAction( + 'key', + addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + ) ) .whenAsyncActionIsDispatched( - updateDataSourceVariableOptions(toVariableIdentifier(datasource), dependencies), + updateDataSourceVariableOptions(toKeyedVariableIdentifier(datasource), dependencies), true ); tester.thenDispatchedActionsShouldEqual( - createDataSourceOptions( - toVariablePayload( - { type: 'datasource', id: '0' }, - { - sources, - regex: undefined as unknown as RegExp, - } + toKeyedAction( + 'key', + createDataSourceOptions( + toVariablePayload( + { type: 'datasource', id: '0' }, + { + sources, + regex: undefined as unknown as RegExp, + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'datasource', id: '0' }, - { option: { text: 'first-name', value: 'first-name', selected: false } } + toKeyedAction( + 'key', + setCurrentVariableValue( + toVariablePayload( + { type: 'datasource', id: '0' }, + { option: { text: 'first-name', value: 'first-name', selected: false } } + ) ) ) ); @@ -99,27 +109,36 @@ describe('data source actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + toKeyedAction( + 'key', + addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + ) ) .whenAsyncActionIsDispatched( - updateDataSourceVariableOptions(toVariableIdentifier(datasource), dependencies), + updateDataSourceVariableOptions(toKeyedVariableIdentifier(datasource), dependencies), true ); tester.thenDispatchedActionsShouldEqual( - createDataSourceOptions( - toVariablePayload( - { type: 'datasource', id: '0' }, - { - sources, - regex: /.*(second-name).*/, - } + toKeyedAction( + 'key', + createDataSourceOptions( + toVariablePayload( + { type: 'datasource', id: '0' }, + { + sources, + regex: /.*(second-name).*/, + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'datasource', id: '0' }, - { option: { text: 'second-name', value: 'second-name', selected: false } } + toKeyedAction( + 'key', + setCurrentVariableValue( + toVariablePayload( + { type: 'datasource', id: '0' }, + { option: { text: 'second-name', value: 'second-name', selected: false } } + ) ) ) ); @@ -143,14 +162,17 @@ describe('data source actions', () => { reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(initDataSourceVariableEditor(dependencies)) + .whenActionIsDispatched(initDataSourceVariableEditor('key', dependencies)) .thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ - dataSourceTypes: [ - { text: '', value: '' }, - { text: 'mock-data-name', value: 'mock-data-id' }, - ], - }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ + dataSourceTypes: [ + { text: '', value: '' }, + { text: 'mock-data-name', value: 'mock-data-id' }, + ], + }) + ) ); expect(getListMock).toHaveBeenCalledTimes(1); diff --git a/public/app/features/variables/datasource/actions.ts b/public/app/features/variables/datasource/actions.ts index f7a9b031828..dc7b34cdd75 100644 --- a/public/app/features/variables/datasource/actions.ts +++ b/public/app/features/variables/datasource/actions.ts @@ -2,7 +2,7 @@ import { chain } from 'lodash'; import { getTemplateSrv } from '@grafana/runtime'; import { stringToJsRegex } from '@grafana/data'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createDataSourceOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; @@ -10,6 +10,8 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { getVariable } from '../state/selectors'; import { DataSourceVariableModel } from '../types'; import { changeVariableEditorExtended } from '../editor/reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export interface DataSourceVariableActionDependencies { getDatasourceSrv: typeof getDatasourceSrv; @@ -17,12 +19,13 @@ export interface DataSourceVariableActionDependencies { export const updateDataSourceVariableOptions = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } ): ThunkResult => async (dispatch, getState) => { + const { rootStateKey } = identifier; const sources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: false }); - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); let regex; if (variableInState.regex) { @@ -30,12 +33,15 @@ export const updateDataSourceVariableOptions = regex = stringToJsRegex(regex); } - dispatch(createDataSourceOptions(toVariablePayload(identifier, { sources, regex }))); + dispatch(toKeyedAction(rootStateKey, createDataSourceOptions(toVariablePayload(identifier, { sources, regex })))); await dispatch(validateVariableSelectionState(identifier)); }; export const initDataSourceVariableEditor = - (dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv }): ThunkResult => + ( + key: string, + dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } + ): ThunkResult => (dispatch) => { const dataSources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: true }); const dataSourceTypes = chain(dataSources) @@ -47,5 +53,5 @@ export const initDataSourceVariableEditor = dataSourceTypes.unshift({ text: '', value: '' }); - dispatch(changeVariableEditorExtended({ dataSourceTypes })); + dispatch(toKeyedAction(key, changeVariableEditorExtended({ dataSourceTypes }))); }; diff --git a/public/app/features/variables/datasource/adapter.ts b/public/app/features/variables/datasource/adapter.ts index a3c8171558a..9b4da36805a 100644 --- a/public/app/features/variables/datasource/adapter.ts +++ b/public/app/features/variables/datasource/adapter.ts @@ -4,10 +4,9 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { dataSourceVariableReducer, initialDataSourceVariableModelState } from './reducer'; -import { toVariableIdentifier } from '../state/types'; import { DataSourceVariableEditor } from './DataSourceVariableEditor'; import { updateDataSourceVariableOptions } from './actions'; -import { containsVariable, isAllVariable } from '../utils'; +import { containsVariable, isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -27,16 +26,16 @@ export const createDataSourceVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateDataSourceVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateDataSourceVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return { ...rest, options: [] }; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/datasource/reducer.test.ts b/public/app/features/variables/datasource/reducer.test.ts index 7fc17be7d41..dce1a1ed93f 100644 --- a/public/app/features/variables/datasource/reducer.test.ts +++ b/public/app/features/variables/datasource/reducer.test.ts @@ -1,14 +1,15 @@ +import { cloneDeep } from 'lodash'; import { DataSourceInstanceSettings } from '@grafana/data'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { createDataSourceOptions, dataSourceVariableReducer } from './reducer'; import { DataSourceVariableModel } from '../types'; import { getVariableTestContext } from '../state/helpers'; -import { cloneDeep } from 'lodash'; import { createDataSourceVariableAdapter } from './adapter'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; +import { toVariablePayload } from '../utils'; describe('dataSourceVariableReducer', () => { const adapter = createDataSourceVariableAdapter(); diff --git a/public/app/features/variables/datasource/reducer.ts b/public/app/features/variables/datasource/reducer.ts index 1a6cb0619d3..3e2370dd495 100644 --- a/public/app/features/variables/datasource/reducer.ts +++ b/public/app/features/variables/datasource/reducer.ts @@ -2,8 +2,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourceVariableModel, initialVariableModelState, VariableOption, VariableRefresh } from '../types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; export const initialDataSourceVariableModelState: DataSourceVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/editor/SelectionOptionsEditor.tsx b/public/app/features/variables/editor/SelectionOptionsEditor.tsx index 00ff36f0966..d1e2688cc40 100644 --- a/public/app/features/variables/editor/SelectionOptionsEditor.tsx +++ b/public/app/features/variables/editor/SelectionOptionsEditor.tsx @@ -4,14 +4,15 @@ import { selectors } from '@grafana/e2e-selectors'; import { VariableWithMultiSupport } from '../types'; import { VariableEditorProps } from './types'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { VariableSectionHeader } from './VariableSectionHeader'; import { VariableSwitchField } from './VariableSwitchField'; import { VariableTextField } from './VariableTextField'; +import { toKeyedVariableIdentifier } from '../utils'; export interface SelectionOptionsEditorProps extends VariableEditorProps { - onMultiChanged: (identifier: VariableIdentifier, value: boolean) => void; + onMultiChanged: (identifier: KeyedVariableIdentifier, value: boolean) => void; } export const SelectionOptionsEditor: FunctionComponent = ({ @@ -21,7 +22,7 @@ export const SelectionOptionsEditor: FunctionComponent { const onMultiChanged = useCallback( (event: ChangeEvent) => { - onMultiChangedProps(toVariableIdentifier(variable), event.target.checked); + onMultiChangedProps(toKeyedVariableIdentifier(variable), event.target.checked); }, [onMultiChangedProps, variable] ); diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 6a82dd09b4a..745e368a37c 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -1,36 +1,61 @@ import React, { MouseEvent, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; import { Icon, LinkButton } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; -import { StoreState } from '../../../types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { StoreState, ThunkDispatch } from '../../../types'; import { VariableEditorEditor } from './VariableEditorEditor'; -import { connect, ConnectedProps } from 'react-redux'; -import { getEditorVariables } from '../state/selectors'; +import { getEditorVariables, getVariablesState } from '../state/selectors'; import { switchToEditMode, switchToListMode, switchToNewMode } from './actions'; import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer'; import { VariableEditorList } from './VariableEditorList'; import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable'; import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - variables: getEditorVariables(state), - idInEditor: state.templating.editor.id, - dashboard: state.dashboard.getModel(), - usagesNetwork: state.templating.inspect.usagesNetwork, - usages: state.templating.inspect.usages, -}); - -const mapDispatchToProps = { - changeVariableOrder, - duplicateVariable, - removeVariable, - switchToNewMode, - switchToEditMode, - switchToListMode, +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { uid } = ownProps.dashboard; + const templatingState = getVariablesState(uid, state); + return { + variables: getEditorVariables(uid, state), + idInEditor: templatingState.editor.id, + usagesNetwork: templatingState.inspect.usagesNetwork, + usages: templatingState.inspect.usages, + }; }; -interface OwnProps {} +const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators({ switchToNewMode, switchToEditMode, switchToListMode }, dispatch), + changeVariableOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex })) + ) + ), + duplicateVariable: (identifier: KeyedVariableIdentifier) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + duplicateVariable(toVariablePayload(identifier, { newId: undefined as unknown as string })) + ) + ), + removeVariable: (identifier: KeyedVariableIdentifier) => { + dispatch( + toKeyedAction(identifier.rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: true }))) + ); + }, + }; +}; + +interface OwnProps { + dashboard: DashboardModel; +} const connector = connect(mapStateToProps, mapDispatchToProps); @@ -38,32 +63,32 @@ type Props = OwnProps & ConnectedProps; class VariableEditorContainerUnconnected extends PureComponent { componentDidMount(): void { - this.props.switchToListMode(); + this.props.switchToListMode(this.props.dashboard.uid); } onChangeToListMode = (event: MouseEvent) => { event.preventDefault(); - this.props.switchToListMode(); + this.props.switchToListMode(this.props.dashboard.uid); }; - onEditVariable = (identifier: VariableIdentifier) => { + onEditVariable = (identifier: KeyedVariableIdentifier) => { this.props.switchToEditMode(identifier); }; onNewVariable = () => { - this.props.switchToNewMode(); + this.props.switchToNewMode(this.props.dashboard.uid); }; - onChangeVariableOrder = (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => { - this.props.changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex })); + onChangeVariableOrder = (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => { + this.props.changeVariableOrder(identifier, fromIndex, toIndex); }; - onDuplicateVariable = (identifier: VariableIdentifier) => { - this.props.duplicateVariable(toVariablePayload(identifier, { newId: undefined as unknown as string })); + onDuplicateVariable = (identifier: KeyedVariableIdentifier) => { + this.props.duplicateVariable(identifier); }; - onRemoveVariable = (identifier: VariableIdentifier) => { - this.props.removeVariable(toVariablePayload(identifier, { reIndex: true })); + onRemoveVariable = (identifier: KeyedVariableIdentifier) => { + this.props.removeVariable(identifier); }; render() { @@ -117,7 +142,7 @@ class VariableEditorContainerUnconnected extends PureComponent { {!variableToEdit && this.props.variables.length > 0 && ( )} - {variableToEdit && } + {variableToEdit && }
); } diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index a1e6455f1e3..58441e2eff1 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -1,20 +1,17 @@ import React, { FormEvent, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; import { isEqual } from 'lodash'; import { AppEvents, LoadingState, SelectableValue, VariableType } from '@grafana/data'; import { Button, Icon, InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { variableAdapters } from '../adapters'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; -import { VariableHide, VariableModel } from '../types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { VariableHide } from '../types'; import { appEvents } from '../../../core/core'; import { VariableValuesPreview } from './VariableValuesPreview'; import { changeVariableName, onEditorUpdate, variableEditorMount, variableEditorUnMount } from './actions'; -import { MapDispatchToProps, MapStateToProps } from 'react-redux'; -import { StoreState } from '../../../types'; -import { VariableEditorState } from './reducer'; -import { getVariable } from '../state/selectors'; -import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; import { OnPropChangeArguments } from './types'; import { changeVariableProp, changeVariableType } from '../state/sharedReducer'; import { updateOptions } from '../state/actions'; @@ -23,27 +20,41 @@ import { VariableSectionHeader } from './VariableSectionHeader'; import { hasOptions } from '../guard'; import { VariableTypeSelect } from './VariableTypeSelect'; import { VariableHideSelect } from './VariableHideSelect'; +import { getVariable, getVariablesState } from '../state/selectors'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { StoreState, ThunkDispatch } from '../../../types'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; + +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({ + editor: getVariablesState(ownProps.identifier.rootStateKey, state).editor, + variable: getVariable(ownProps.identifier, state, false), // we could be renaming a variable and we don't want this to throw +}); + +const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators( + { variableEditorMount, variableEditorUnMount, changeVariableName, onEditorUpdate, updateOptions }, + dispatch + ), + changeVariableProp: (identifier: KeyedVariableIdentifier, propName: string, propValue: any) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName, propValue })) + ) + ), + changeVariableType: (identifier: KeyedVariableIdentifier, newType: VariableType) => + dispatch(toKeyedAction(identifier.rootStateKey, changeVariableType(toVariablePayload(identifier, { newType })))), + }; +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); export interface OwnProps { - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; } -interface ConnectedProps { - editor: VariableEditorState; - variable: VariableModel; -} - -interface DispatchProps { - variableEditorMount: typeof variableEditorMount; - variableEditorUnMount: typeof variableEditorUnMount; - changeVariableName: typeof changeVariableName; - changeVariableProp: typeof changeVariableProp; - onEditorUpdate: typeof onEditorUpdate; - changeVariableType: typeof changeVariableType; - updateOptions: typeof updateOptions; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; +type Props = OwnProps & ConnectedProps; export class VariableEditorEditorUnConnected extends PureComponent { componentDidMount(): void { @@ -71,35 +82,26 @@ export class VariableEditorEditorUnConnected extends PureComponent { if (!option.value) { return; } - this.props.changeVariableType(toVariablePayload(this.props.identifier, { newType: option.value })); + this.props.changeVariableType(this.props.identifier, option.value); }; onLabelChange = (event: FormEvent) => { event.preventDefault(); - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.currentTarget.value }) - ); + this.props.changeVariableProp(this.props.identifier, 'label', event.currentTarget.value); }; onDescriptionChange = (event: FormEvent) => { - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'description', propValue: event.currentTarget.value }) - ); + this.props.changeVariableProp(this.props.identifier, 'description', event.currentTarget.value); }; onHideChange = (option: SelectableValue) => { - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { - propName: 'hide', - propValue: option.value, - }) - ); + this.props.changeVariableProp(this.props.identifier, 'hide', option.value); }; onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => { - this.props.changeVariableProp(toVariablePayload(this.props.identifier, { propName, propValue })); + this.props.changeVariableProp(this.props.identifier, propName, propValue); if (updateOptions) { - await this.props.updateOptions(toVariableIdentifier(this.props.variable)); + await this.props.updateOptions(toKeyedVariableIdentifier(this.props.variable)); } }; @@ -191,23 +193,4 @@ export class VariableEditorEditorUnConnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state, ownProps) => ({ - editor: state.templating.editor, - variable: getVariable(ownProps.identifier.id, state, false), // we could be renaming a variable and we don't want this to throw -}); - -const mapDispatchToProps: MapDispatchToProps = { - variableEditorMount, - variableEditorUnMount, - changeVariableName, - changeVariableProp, - onEditorUpdate, - changeVariableType, - updateOptions, -}; - -export const VariableEditorEditor = connectWithStore( - VariableEditorEditorUnConnected, - mapStateToProps, - mapDispatchToProps -); +export const VariableEditorEditor = connector(VariableEditorEditorUnConnected); diff --git a/public/app/features/variables/editor/VariableEditorList.tsx b/public/app/features/variables/editor/VariableEditorList.tsx index b6f27b4fe9c..c3ac0e585cd 100644 --- a/public/app/features/variables/editor/VariableEditorList.tsx +++ b/public/app/features/variables/editor/VariableEditorList.tsx @@ -4,7 +4,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { VariableModel } from '../types'; -import { VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { VariableEditorListRow } from './VariableEditorListRow'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; @@ -14,10 +14,10 @@ export interface Props { usages: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[]; onAdd: () => void; - onEdit: (identifier: VariableIdentifier) => void; - onChangeOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void; - onDuplicate: (identifier: VariableIdentifier) => void; - onDelete: (identifier: VariableIdentifier) => void; + onEdit: (identifier: KeyedVariableIdentifier) => void; + onChangeOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => void; + onDuplicate: (identifier: KeyedVariableIdentifier) => void; + onDelete: (identifier: KeyedVariableIdentifier) => void; } export function VariableEditorList({ diff --git a/public/app/features/variables/editor/VariableEditorListRow.tsx b/public/app/features/variables/editor/VariableEditorListRow.tsx index 6d117fdcd1a..68b589cbdd9 100644 --- a/public/app/features/variables/editor/VariableEditorListRow.tsx +++ b/public/app/features/variables/editor/VariableEditorListRow.tsx @@ -8,18 +8,19 @@ import { reportInteraction } from '@grafana/runtime'; import { getVariableUsages, UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { hasOptions, isAdHoc, isQuery } from '../guard'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { VariableUsagesButton } from '../inspect/VariableUsagesButton'; import { VariableModel } from '../types'; +import { toKeyedVariableIdentifier } from '../utils'; export interface VariableEditorListRowProps { index: number; variable: VariableModel; usageTree: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[]; - onEdit: (identifier: VariableIdentifier) => void; - onDuplicate: (identifier: VariableIdentifier) => void; - onDelete: (identifier: VariableIdentifier) => void; + onEdit: (identifier: KeyedVariableIdentifier) => void; + onDuplicate: (identifier: KeyedVariableIdentifier) => void; + onDelete: (identifier: KeyedVariableIdentifier) => void; } export function VariableEditorListRow({ @@ -36,7 +37,7 @@ export function VariableEditorListRow({ const definition = getDefinition(variable); const usages = getVariableUsages(variable.id, usageTree); const passed = usages > 0 || isAdHoc(variable); - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); return ( diff --git a/public/app/features/variables/editor/actions.ts b/public/app/features/variables/editor/actions.ts index 1aa5af6f93e..55d9b12c2a4 100644 --- a/public/app/features/variables/editor/actions.ts +++ b/public/app/features/variables/editor/actions.ts @@ -1,5 +1,5 @@ import { ThunkResult } from '../../../types'; -import { getEditorVariables, getNewVariableIndex, getVariable, getVariables } from '../state/selectors'; +import { getEditorVariables, getNewVariableIndex, getVariable, getVariablesByKey } from '../state/selectors'; import { changeVariableNameFailed, changeVariableNameSucceeded, @@ -9,7 +9,7 @@ import { variableEditorUnMounted, } from './reducer'; import { variableAdapters } from '../adapters'; -import { AddVariable, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { AddVariable, KeyedVariableIdentifier, VariableIdentifier } from '../state/types'; import { cloneDeep } from 'lodash'; import { VariableType } from '@grafana/data'; import { addVariable, removeVariable } from '../state/sharedReducer'; @@ -17,28 +17,33 @@ import { updateOptions } from '../state/actions'; import { VariableModel } from '../types'; import { initInspect } from '../inspect/reducer'; import { createUsagesNetwork, transformUsagesToNetwork } from '../inspect/utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; -export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult => { +export const variableEditorMount = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - dispatch(variableEditorMounted({ name: getVariable(identifier.id).name })); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, variableEditorMounted({ name: getVariable(identifier).name }))); }; }; -export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResult => { +export const variableEditorUnMount = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch, getState) => { - dispatch(variableEditorUnMounted(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, variableEditorUnMounted(toVariablePayload(identifier)))); }; }; -export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult => { +export const onEditorUpdate = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { await dispatch(updateOptions(identifier)); - dispatch(switchToListMode()); + dispatch(switchToListMode(identifier.rootStateKey)); }; }; -export const changeVariableName = (identifier: VariableIdentifier, newName: string): ThunkResult => { +export const changeVariableName = (identifier: KeyedVariableIdentifier, newName: string): ThunkResult => { return (dispatch, getState) => { + const { id, rootStateKey: uid } = identifier; let errorText = null; if (!newName.match(/^(?!__).*$/)) { errorText = "Template names cannot begin with '__', that's reserved for Grafana's global variables"; @@ -48,15 +53,15 @@ export const changeVariableName = (identifier: VariableIdentifier, newName: stri errorText = 'Only word and digit characters are allowed in variable names'; } - const variables = getVariables(getState()); - const foundVariables = variables.filter((v) => v.name === newName && v.id !== identifier.id); + const variables = getVariablesByKey(uid, getState()); + const foundVariables = variables.filter((v) => v.name === newName && v.id !== id); if (foundVariables.length) { errorText = 'Variable with the same name already exists'; } if (errorText) { - dispatch(changeVariableNameFailed({ newName, errorText })); + dispatch(toKeyedAction(uid, changeVariableNameFailed({ newName, errorText }))); return; } @@ -65,54 +70,61 @@ export const changeVariableName = (identifier: VariableIdentifier, newName: stri }; export const completeChangeVariableName = - (identifier: VariableIdentifier, newName: string): ThunkResult => + (identifier: KeyedVariableIdentifier, newName: string): ThunkResult => (dispatch, getState) => { - const originalVariable = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const originalVariable = getVariable(identifier, getState()); if (originalVariable.name === newName) { - dispatch(changeVariableNameSucceeded(toVariablePayload(identifier, { newName }))); + dispatch(toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(identifier, { newName })))); return; } const model = { ...cloneDeep(originalVariable), name: newName, id: newName }; const global = originalVariable.global; const index = originalVariable.index; - const renamedIdentifier = toVariableIdentifier(model); + const renamedIdentifier = toKeyedVariableIdentifier(model); - dispatch(addVariable(toVariablePayload(renamedIdentifier, { global, index, model }))); - dispatch(changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName }))); + dispatch(toKeyedAction(rootStateKey, addVariable(toVariablePayload(renamedIdentifier, { global, index, model })))); + dispatch( + toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName }))) + ); dispatch(switchToEditMode(renamedIdentifier)); - dispatch(removeVariable(toVariablePayload(identifier, { reIndex: false }))); + dispatch(toKeyedAction(rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: false })))); }; export const switchToNewMode = - (type: VariableType = 'query'): ThunkResult => + (key: string, type: VariableType = 'query'): ThunkResult => (dispatch, getState) => { - const id = getNextAvailableId(type, getVariables(getState())); - const identifier = { type, id }; + const id = getNextAvailableId(type, getVariablesByKey(key, getState())); + const identifier: VariableIdentifier = { type, id }; const global = false; - const index = getNewVariableIndex(getState()); - const model = cloneDeep(variableAdapters.get(type).initialState); + const index = getNewVariableIndex(key, getState()); + const model: VariableModel = cloneDeep(variableAdapters.get(type).initialState); model.id = id; model.name = id; - dispatch(addVariable(toVariablePayload(identifier, { global, model, index }))); - dispatch(setIdInEditor({ id: identifier.id })); + model.rootStateKey = key; + dispatch(toKeyedAction(key, addVariable(toVariablePayload(identifier, { global, model, index })))); + dispatch(toKeyedAction(key, setIdInEditor({ id: identifier.id }))); }; export const switchToEditMode = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => (dispatch) => { - dispatch(setIdInEditor({ id: identifier.id })); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, setIdInEditor({ id: identifier.id }))); }; -export const switchToListMode = (): ThunkResult => (dispatch, getState) => { - dispatch(clearIdInEditor()); - const state = getState(); - const variables = getEditorVariables(state); - const dashboard = state.dashboard.getModel(); - const { usages } = createUsagesNetwork(variables, dashboard); - const usagesNetwork = transformUsagesToNetwork(usages); +export const switchToListMode = + (key: string): ThunkResult => + (dispatch, getState) => { + dispatch(toKeyedAction(key, clearIdInEditor())); + const state = getState(); + const variables = getEditorVariables(key, state); + const dashboard = state.dashboard.getModel(); + const { usages } = createUsagesNetwork(variables, dashboard); + const usagesNetwork = transformUsagesToNetwork(usages); - dispatch(initInspect({ usages, usagesNetwork })); -}; + dispatch(toKeyedAction(key, initInspect({ usages, usagesNetwork }))); + }; export function getNextAvailableId(type: VariableType, variables: VariableModel[]): string { let counter = 0; diff --git a/public/app/features/variables/editor/reducer.test.ts b/public/app/features/variables/editor/reducer.test.ts index 1fa8002a659..e1ed467bb3f 100644 --- a/public/app/features/variables/editor/reducer.test.ts +++ b/public/app/features/variables/editor/reducer.test.ts @@ -14,7 +14,7 @@ import { VariableEditorState, variableEditorUnMounted, } from './reducer'; -import { toVariablePayload } from '../state/types'; +import { toVariablePayload } from '../utils'; describe('variableEditorReducer', () => { describe('when setIdInEditor is dispatched', () => { diff --git a/public/app/features/variables/getAllVariableValuesForUrl.test.ts b/public/app/features/variables/getAllVariableValuesForUrl.test.ts index 7eb44ac7be0..dbd04260212 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.test.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.test.ts @@ -4,6 +4,8 @@ import { createQueryVariableAdapter } from './query/adapter'; import { getVariablesUrlParams } from './getAllVariableValuesForUrl'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; +const key = 'key'; + describe('getAllVariableValuesForUrl', () => { beforeAll(() => { variableAdapters.register(createQueryVariableAdapter()); @@ -12,10 +14,11 @@ describe('getAllVariableValuesForUrl', () => { describe('with multi value', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { type: 'query', name: 'test', + rootStateKey: key, current: { value: ['val1', 'val2'] }, getValueForUrl: function () { return this.current.value; @@ -34,9 +37,10 @@ describe('getAllVariableValuesForUrl', () => { describe('skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { name: 'test', + rootStateKey: key, skipUrlSync: true, current: { value: 'value' }, getValueForUrl: function () { @@ -56,10 +60,11 @@ describe('getAllVariableValuesForUrl', () => { describe('with multi value with skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { type: 'query', name: 'test', + rootStateKey: key, skipUrlSync: true, current: { value: ['val1', 'val2'] }, getValueForUrl: function () { @@ -78,7 +83,9 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value and scopedVars', () => { beforeEach(() => { - setTemplateSrv(initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }])); + setTemplateSrv( + initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + ); }); it('should set scoped value as url params', () => { @@ -91,7 +98,9 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', () => { beforeEach(() => { - setTemplateSrv(initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }])); + setTemplateSrv( + initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + ); }); it('should not set scoped value as url params', () => { diff --git a/public/app/features/variables/getAllVariableValuesForUrl.ts b/public/app/features/variables/getAllVariableValuesForUrl.ts index 4cfbfd55d33..e775394f287 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.ts @@ -6,7 +6,6 @@ export function getVariablesUrlParams(scopedVars?: ScopedVars): UrlQueryMap { const params: UrlQueryMap = {}; const variables = getTemplateSrv().getVariables(); - // console.log(variables) for (let i = 0; i < variables.length; i++) { const variable = variables[i]; if (scopedVars && scopedVars[variable.name] !== void 0) { diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index 8e320fa6b18..007caacc7a7 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,6 +1,5 @@ import { getRootReducer, RootReducerType } from '../state/helpers'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { updateAutoValue, UpdateAutoValueDependencies, updateIntervalVariableOptions } from './actions'; import { createIntervalOptions } from './reducer'; import { @@ -20,25 +19,37 @@ import { notifyApp } from '../../../core/actions'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { variablesInitTransaction } from '../state/transactionReducer'; import { afterEach, beforeEach } from '../../../../test/lib/common'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('interval actions', () => { variableAdapters.setInit(() => [createIntervalVariableAdapter()]); describe('when updateIntervalVariableOptions is dispatched', () => { it('then correct actions are dispatched', async () => { - const interval = intervalBuilder().withId('0').withQuery('1s,1m,1h,1d').withAuto(false).build(); + const interval = intervalBuilder() + .withId('0') + .withRootStateKey('key') + .withQuery('1s,1m,1h,1d') + .withAuto(false) + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) - .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toVariableIdentifier(interval)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + ) + .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toKeyedVariableIdentifier(interval)), true); tester.thenDispatchedActionsShouldEqual( - createIntervalOptions({ type: 'interval', id: '0', data: undefined }), - setCurrentVariableValue({ - type: 'interval', - id: '0', - data: { option: { text: '1s', value: '1s', selected: false } }, - }) + toKeyedAction('key', createIntervalOptions({ type: 'interval', id: '0', data: undefined })), + toKeyedAction( + 'key', + setCurrentVariableValue({ + type: 'interval', + id: '0', + data: { option: { text: '1s', value: '1s', selected: false } }, + }) + ) ); }); }); @@ -67,6 +78,7 @@ describe('interval actions', () => { it('then an notifyApp action should be dispatched', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withQuery('1s,1m,1h,1d') .withAuto(true) .withAutoMin('1xyz') // illegal interval string @@ -74,21 +86,26 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(interval)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { const expectedNumberOfActions = 4; - expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(interval))); - expect(dispatchedActions[1]).toEqual(createIntervalOptions(toVariablePayload(interval))); + expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(interval)))); + expect(dispatchedActions[1]).toEqual(toKeyedAction('key', createIntervalOptions(toVariablePayload(interval)))); expect(dispatchedActions[2]).toEqual( - variableStateFailed( - toVariablePayload(interval, { - error: new Error( - 'Invalid interval string, has to be either unit-less or end with one of the following units: "y, M, w, d, h, m, s, ms"' - ), - }) + toKeyedAction( + 'key', + variableStateFailed( + toVariablePayload(interval, { + error: new Error( + 'Invalid interval string, has to be either unit-less or end with one of the following units: "y, M, w, d, h, m, s, ms"' + ), + }) + ) ) ); @@ -107,6 +124,7 @@ describe('interval actions', () => { it('then no actions are dispatched', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withQuery('1s,1m,1h,1d') .withAuto(true) .withAutoMin('1xyz') // illegal interval string @@ -115,9 +133,9 @@ describe('interval actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(interval)), true); tester.thenNoActionsWhereDispatched(); }); @@ -127,7 +145,7 @@ describe('interval actions', () => { describe('when updateAutoValue is dispatched', () => { describe('and auto is false', () => { it('then no dependencies are called', async () => { - const interval = intervalBuilder().withId('0').withAuto(false).build(); + const interval = intervalBuilder().withId('0').withRootStateKey('key').withAuto(false).build(); const dependencies: UpdateAutoValueDependencies = { calculateInterval: jest.fn(), @@ -151,9 +169,9 @@ describe('interval actions', () => { await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateAutoValue(toVariableIdentifier(interval), dependencies), true); + .whenAsyncActionIsDispatched(updateAutoValue(toKeyedVariableIdentifier(interval), dependencies), true); expect(dependencies.calculateInterval).toHaveBeenCalledTimes(0); expect(dependencies.getTimeSrv().timeRange).toHaveBeenCalledTimes(0); @@ -165,6 +183,7 @@ describe('interval actions', () => { it('then correct dependencies are called', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withName('intervalName') .withAuto(true) .withAutoCount(33) @@ -195,9 +214,9 @@ describe('interval actions', () => { await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateAutoValue(toVariableIdentifier(interval), dependencies), true); + .whenAsyncActionIsDispatched(updateAutoValue(toKeyedVariableIdentifier(interval), dependencies), true); expect(dependencies.calculateInterval).toHaveBeenCalledTimes(1); expect(dependencies.calculateInterval).toHaveBeenCalledWith( diff --git a/public/app/features/variables/interval/actions.ts b/public/app/features/variables/interval/actions.ts index c75a724e8dd..bc3f4d8d521 100644 --- a/public/app/features/variables/interval/actions.ts +++ b/public/app/features/variables/interval/actions.ts @@ -1,6 +1,6 @@ import { rangeUtil } from '@grafana/data'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createIntervalOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; @@ -8,11 +8,14 @@ import { getVariable } from '../state/selectors'; import { IntervalVariableModel } from '../types'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export const updateIntervalVariableOptions = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => async (dispatch) => { - await dispatch(createIntervalOptions(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createIntervalOptions(toVariablePayload(identifier)))); await dispatch(updateAutoValue(identifier)); await dispatch(validateVariableSelectionState(identifier)); }; @@ -25,7 +28,7 @@ export interface UpdateAutoValueDependencies { export const updateAutoValue = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, dependencies: UpdateAutoValueDependencies = { calculateInterval: rangeUtil.calculateInterval, getTimeSrv: getTimeSrv, @@ -33,7 +36,7 @@ export const updateAutoValue = } ): ThunkResult => (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); if (variableInState.auto) { const res = dependencies.calculateInterval( dependencies.getTimeSrv().timeRange(), diff --git a/public/app/features/variables/interval/adapter.ts b/public/app/features/variables/interval/adapter.ts index 36402c2e866..69e523836a6 100644 --- a/public/app/features/variables/interval/adapter.ts +++ b/public/app/features/variables/interval/adapter.ts @@ -4,10 +4,10 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { initialIntervalVariableModelState, intervalVariableReducer } from './reducer'; -import { toVariableIdentifier } from '../state/types'; import { IntervalVariableEditor } from './IntervalVariableEditor'; import { updateAutoValue, updateIntervalVariableOptions } from './actions'; import { optionPickerFactory } from '../pickers'; +import { toKeyedVariableIdentifier } from '../utils'; export const createIntervalVariableAdapter = (): VariableAdapter => { return { @@ -22,18 +22,18 @@ export const createIntervalVariableAdapter = (): VariableAdapter { - await dispatch(updateAutoValue(toVariableIdentifier(variable))); - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(updateAutoValue(toKeyedVariableIdentifier(variable))); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(updateAutoValue(toVariableIdentifier(variable))); - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(updateAutoValue(toKeyedVariableIdentifier(variable))); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateIntervalVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateIntervalVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/interval/reducer.test.ts b/public/app/features/variables/interval/reducer.test.ts index 3faf98a3281..e5b04df6ae8 100644 --- a/public/app/features/variables/interval/reducer.test.ts +++ b/public/app/features/variables/interval/reducer.test.ts @@ -1,11 +1,12 @@ import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createIntervalVariableAdapter } from './adapter'; import { IntervalVariableModel } from '../types'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { createIntervalOptions, intervalVariableReducer } from './reducer'; +import { toVariablePayload } from '../utils'; describe('intervalVariableReducer', () => { const adapter = createIntervalVariableAdapter(); diff --git a/public/app/features/variables/interval/reducer.ts b/public/app/features/variables/interval/reducer.ts index 09e65357a8a..faadce99a6a 100644 --- a/public/app/features/variables/interval/reducer.ts +++ b/public/app/features/variables/interval/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialVariableModelState, IntervalVariableModel, VariableOption, VariableRefresh } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { map } from 'lodash'; +import { getInstanceState } from '../state/selectors'; export const initialIntervalVariableModelState: IntervalVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx index 075908f023d..36a1a50e17e 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx @@ -9,7 +9,8 @@ import { VariablePickerProps } from '../types'; import { QueryVariableModel, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { queryBuilder } from '../../shared/testing/builders'; import { optionPickerFactory } from './OptionsPicker'; -import { initialState, OptionsPickerState } from './reducer'; +import { initialOptionPickerState, OptionsPickerState } from './reducer'; +import { getPreloadedState } from '../../state/helpers'; interface Args { pickerState?: Partial; @@ -18,6 +19,7 @@ interface Args { const defaultVariable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) @@ -35,17 +37,16 @@ function setupTestContext({ pickerState = {}, variable = {} }: Args = {}) { onVariableChange, }; const Picker = optionPickerFactory(); - const optionsPicker: OptionsPickerState = { ...initialState, ...pickerState }; + const optionsPicker: OptionsPickerState = { ...initialOptionPickerState, ...pickerState }; const dispatch = jest.fn(); const subscribe = jest.fn(); - const getState = jest.fn().mockReturnValue({ - templating: { - variables: { - [v.id]: { ...v }, - }, - optionsPicker, + const templatingState = { + variables: { + [v.id]: { ...v }, }, - }); + optionsPicker, + }; + const getState = jest.fn().mockReturnValue(getPreloadedState('key', templatingState)); const store: any = { getState, dispatch, subscribe }; const { rerender } = render( diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx index 9c082a44658..c33a9afc05b 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx @@ -1,36 +1,58 @@ import React, { ComponentType, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; import { connect, ConnectedProps } from 'react-redux'; import { ClickOutsideWrapper } from '@grafana/ui'; import { LoadingState } from '@grafana/data'; -import { StoreState } from 'app/types'; +import { StoreState, ThunkDispatch } from 'app/types'; import { VariableInput } from '../shared/VariableInput'; import { commitChangesToVariable, filterOrSearchOptions, navigateOptions, openOptions } from './actions'; -import { OptionsPickerState, toggleAllOptions, toggleOption } from './reducer'; +import { initialOptionPickerState, OptionsPickerState, toggleAllOptions, toggleOption } from './reducer'; import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { VariableOptions } from '../shared/VariableOptions'; import { isMulti } from '../../guard'; -import { VariablePickerProps } from '../types'; +import { NavigationKey, VariablePickerProps } from '../types'; import { formatVariableLabel } from '../../shared/formatVariable'; -import { toVariableIdentifier } from '../../state/types'; +import { KeyedVariableIdentifier } from '../../state/types'; import { getVariableQueryRunner } from '../../query/VariableQueryRunner'; import { VariableLink } from '../shared/VariableLink'; +import { getVariablesState } from '../../state/selectors'; +import { toKeyedAction } from '../../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier } from '../../utils'; export const optionPickerFactory = (): ComponentType< VariablePickerProps > => { - const mapDispatchToProps = { - openOptions, - commitChangesToVariable, - filterOrSearchOptions, - toggleAllOptions, - toggleOption, - navigateOptions, + const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators({ openOptions, commitChangesToVariable, navigateOptions }, dispatch), + filterOrSearchOptions: (identifier: KeyedVariableIdentifier, filter = '') => { + dispatch(filterOrSearchOptions(identifier, filter)); + }, + toggleAllOptions: (identifier: KeyedVariableIdentifier) => + dispatch(toKeyedAction(identifier.rootStateKey, toggleAllOptions())), + toggleOption: ( + identifier: KeyedVariableIdentifier, + option: VariableOption, + clearOthers: boolean, + forceSelect: boolean + ) => dispatch(toKeyedAction(identifier.rootStateKey, toggleOption({ option, clearOthers, forceSelect }))), + }; }; - const mapStateToProps = (state: StoreState) => ({ - picker: state.templating.optionsPicker, - }); + const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + if (!rootStateKey) { + console.error('OptionPickerFactory: variable has no rootStateKey'); + return { + picker: initialOptionPickerState, + }; + } + + return { + picker: getVariablesState(rootStateKey, state).optionsPicker, + }; + }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -40,8 +62,15 @@ export const optionPickerFactory = { onShowOptions = () => - this.props.openOptions(toVariableIdentifier(this.props.variable), this.props.onVariableChange); - onHideOptions = () => this.props.commitChangesToVariable(this.props.onVariableChange); + this.props.openOptions(toKeyedVariableIdentifier(this.props.variable), this.props.onVariableChange); + onHideOptions = () => { + if (!this.props.variable.rootStateKey) { + console.error('Variable has no rootStateKey'); + return; + } + + this.props.commitChangesToVariable(this.props.variable.rootStateKey, this.props.onVariableChange); + }; onToggleOption = (option: VariableOption, clearOthers: boolean) => { const toggleFunc = @@ -52,12 +81,29 @@ export const optionPickerFactory = { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.props.toggleOption(toKeyedVariableIdentifier(this.props.variable), option, clearOthers, false); this.onHideOptions(); }; onToggleMultiValueVariable = (option: VariableOption, clearOthers: boolean) => { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.props.toggleOption(toKeyedVariableIdentifier(this.props.variable), option, clearOthers, false); + }; + + onToggleAllOptions = () => { + this.props.toggleAllOptions(toKeyedVariableIdentifier(this.props.variable)); + }; + + onFilterOrSearchOptions = (filter: string) => { + this.props.filterOrSearchOptions(toKeyedVariableIdentifier(this.props.variable), filter); + }; + + onNavigate = (key: NavigationKey, clearOthers: boolean) => { + if (!this.props.variable.rootStateKey) { + console.error('Variable has no rootStateKey'); + return; + } + + this.props.navigateOptions(this.props.variable.rootStateKey, key, clearOthers); }; render() { @@ -87,7 +133,7 @@ export const optionPickerFactory = { - getVariableQueryRunner().cancelRequest(toVariableIdentifier(this.props.variable)); + getVariableQueryRunner().cancelRequest(toKeyedVariableIdentifier(this.props.variable)); }; renderOptions(picker: OptionsPickerState) { @@ -97,15 +143,15 @@ export const optionPickerFactory = Promise.resolve([])), @@ -59,9 +60,11 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); const option = { ...createOption(['A']), @@ -70,9 +73,12 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions() + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()) ); }); }); @@ -91,12 +97,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, false)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, false)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -114,12 +124,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -133,14 +147,18 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option: options[2], forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option: options[2], forceSelect: false, clearOthers })) + ); }); }); @@ -154,15 +172,19 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveUp, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option: options[1], forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option: options[1], forceSelect: false, clearOthers })) + ); }); }); @@ -176,13 +198,15 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveUp, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); const option = { ...createOption(['B']), @@ -191,11 +215,14 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - toggleOption({ option: options[1], forceSelect: true, clearOthers }), - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', toggleOption({ option: options[1], forceSelect: true, clearOthers })), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': ['B'] }); }); @@ -209,11 +236,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(filterOrSearchOptions(filter), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), filter), true); - tester.thenDispatchedActionsShouldEqual(updateSearchQuery(filter), updateOptionsAndFilter(variable.options)); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', updateSearchQuery(filter)), + toKeyedAction('key', updateOptionsAndFilter(variable.options)) + ); }); }); @@ -221,26 +253,25 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - }, - optionsPicker: { ...initialState }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, }, - }; + optionsPicker: { ...initialOptionPickerState }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); - tester.thenDispatchedActionsShouldEqual(showOptions(variable)); + tester.thenDispatchedActionsShouldEqual(toKeyedAction('key', showOptions(variable))); }); }); @@ -248,26 +279,25 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - }, - optionsPicker: { ...initialState, id: variable.id }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, }, - }; + optionsPicker: { ...initialOptionPickerState, id: variable.id }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); - tester.thenDispatchedActionsShouldEqual(showOptions(variable)); + tester.thenDispatchedActionsShouldEqual(toKeyedAction('key', showOptions(variable))); }); }); @@ -275,6 +305,7 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variableInPickerState = queryBuilder() .withId('query1') + .withRootStateKey('key') .withName('query1') .withMulti() .withCurrent(['A', 'C']) @@ -283,31 +314,33 @@ describe('options picker actions', () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - [variableInPickerState.id]: { ...variableInPickerState }, - }, - optionsPicker: { ...initialState, id: variableInPickerState.id }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, + [variableInPickerState.id]: { ...variableInPickerState }, }, - }; + optionsPicker: { ...initialOptionPickerState, id: variableInPickerState.id }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue({ type: 'query', id: 'query1', data: { option: undefined } }), - changeVariableProp({ type: 'query', id: 'query1', data: { propName: 'queryValue', propValue: '' } }), - hideOptions(), - showOptions(variable) + toKeyedAction('key', setCurrentVariableValue({ type: 'query', id: 'query1', data: { option: undefined } })), + toKeyedAction( + 'key', + changeVariableProp({ type: 'query', id: 'query1', data: { propName: 'queryValue', propValue: '' } }) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', showOptions(variable)) ); }); }); @@ -319,9 +352,11 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption(['A']), @@ -330,9 +365,12 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions() + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()) ); }); }); @@ -345,11 +383,13 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption([]), @@ -358,10 +398,13 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': [] }); }); @@ -375,12 +418,14 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)) - .whenActionIsDispatched(filterOrSearchOptions('C')) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)) + .whenActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), 'C')) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption([]), @@ -389,10 +434,13 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': [] }); }); @@ -406,14 +454,18 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers), true); const option = createOption('A'); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -425,25 +477,27 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true) - .whenActionIsDispatched(filterOrSearchOptions('B')) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers), true) + .whenActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), 'B')) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)); const optionA = createOption('A'); const optionBC = createOption('BD'); tester.thenDispatchedActionsShouldEqual( - toggleOption({ option: optionA, forceSelect: false, clearOthers }), - updateSearchQuery('B'), - updateOptionsAndFilter(variable.options), - moveOptionsHighlight(1), - moveOptionsHighlight(1), - toggleOption({ option: optionBC, forceSelect: false, clearOthers }) + toKeyedAction('key', toggleOption({ option: optionA, forceSelect: false, clearOthers })), + toKeyedAction('key', updateSearchQuery('B')), + toKeyedAction('key', updateOptionsAndFilter(variable.options)), + toKeyedAction('key', moveOptionsHighlight(1)), + toKeyedAction('key', moveOptionsHighlight(1)), + toKeyedAction('key', toggleOption({ option: optionBC, forceSelect: false, clearOthers })) ); }); }); @@ -454,6 +508,7 @@ function createMultiVariable(extend?: Partial): QueryVariabl ...initialVariableModelState, type: 'query', id: '0', + rootStateKey: 'key', index: 0, current: createOption([]), options: [], diff --git a/public/app/features/variables/pickers/OptionsPicker/actions.ts b/public/app/features/variables/pickers/OptionsPicker/actions.ts index d6a634689bd..7d25290fb22 100644 --- a/public/app/features/variables/pickers/OptionsPicker/actions.ts +++ b/public/app/features/variables/pickers/OptionsPicker/actions.ts @@ -2,7 +2,7 @@ import { debounce, trim } from 'lodash'; import { StoreState, ThunkDispatch, ThunkResult } from 'app/types'; import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { variableAdapters } from '../../adapters'; -import { getVariable } from '../../state/selectors'; +import { getVariable, getVariablesState } from '../../state/selectors'; import { NavigationKey } from '../types'; import { hideOptions, @@ -15,50 +15,56 @@ import { updateSearchQuery, } from './reducer'; import { changeVariableProp, setCurrentVariableValue } from '../../state/sharedReducer'; -import { toVariablePayload, VariableIdentifier } from '../../state/types'; -import { containsSearchFilter, getCurrentText } from '../../utils'; +import { KeyedVariableIdentifier } from '../../state/types'; +import { containsSearchFilter, getCurrentText, toVariablePayload } from '../../utils'; +import { toKeyedAction } from '../../state/keyedVariablesReducer'; -export const navigateOptions = (key: NavigationKey, clearOthers: boolean): ThunkResult => { +export const navigateOptions = (rootStateKey: string, key: NavigationKey, clearOthers: boolean): ThunkResult => { return async (dispatch, getState) => { if (key === NavigationKey.cancel) { - return await dispatch(commitChangesToVariable()); + return await dispatch(commitChangesToVariable(rootStateKey)); } if (key === NavigationKey.select) { - return dispatch(toggleOptionByHighlight(clearOthers)); + return dispatch(toggleOptionByHighlight(rootStateKey, clearOthers)); } if (key === NavigationKey.selectAndClose) { - dispatch(toggleOptionByHighlight(clearOthers, true)); - return await dispatch(commitChangesToVariable()); + dispatch(toggleOptionByHighlight(rootStateKey, clearOthers, true)); + return await dispatch(commitChangesToVariable(rootStateKey)); } if (key === NavigationKey.moveDown) { - return dispatch(moveOptionsHighlight(1)); + return dispatch(toKeyedAction(rootStateKey, moveOptionsHighlight(1))); } if (key === NavigationKey.moveUp) { - return dispatch(moveOptionsHighlight(-1)); + return dispatch(toKeyedAction(rootStateKey, moveOptionsHighlight(-1))); } return undefined; }; }; -export const filterOrSearchOptions = (searchQuery = ''): ThunkResult => { +export const filterOrSearchOptions = ( + passedIdentifier: KeyedVariableIdentifier, + searchQuery = '' +): ThunkResult => { return async (dispatch, getState) => { - const { id, queryValue } = getState().templating.optionsPicker; - const { query, options } = getVariable(id, getState()); - dispatch(updateSearchQuery(searchQuery)); + const { rootStateKey } = passedIdentifier; + const { id, queryValue } = getVariablesState(rootStateKey, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id, rootStateKey: rootStateKey, type: 'query' }; + const { query, options } = getVariable(identifier, getState()); + dispatch(toKeyedAction(rootStateKey, updateSearchQuery(searchQuery))); if (trim(queryValue) === trim(searchQuery)) { return; } if (containsSearchFilter(query)) { - return searchForOptionsWithDebounce(dispatch, getState, searchQuery); + return searchForOptionsWithDebounce(dispatch, getState, searchQuery, rootStateKey); } - return dispatch(updateOptionsAndFilter(options)); + return dispatch(toKeyedAction(rootStateKey, updateOptionsAndFilter(options))); }; }; @@ -68,17 +74,18 @@ const setVariable = async (updated: VariableWithMultiSupport) => { return; }; -export const commitChangesToVariable = (callback?: (updated: any) => void): ThunkResult => { +export const commitChangesToVariable = (key: string, callback?: (updated: any) => void): ThunkResult => { return async (dispatch, getState) => { - const picker = getState().templating.optionsPicker; - const existing = getVariable(picker.id, getState()); + const picker = getVariablesState(key, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id: picker.id, rootStateKey: key, type: 'query' }; + const existing = getVariable(identifier, getState()); const currentPayload = { option: mapToCurrent(picker) }; const searchQueryPayload = { propName: 'queryValue', propValue: picker.queryValue }; - dispatch(setCurrentVariableValue(toVariablePayload(existing, currentPayload))); - dispatch(changeVariableProp(toVariablePayload(existing, searchQueryPayload))); - const updated = getVariable(picker.id, getState()); - dispatch(hideOptions()); + dispatch(toKeyedAction(key, setCurrentVariableValue(toVariablePayload(existing, currentPayload)))); + dispatch(toKeyedAction(key, changeVariableProp(toVariablePayload(existing, searchQueryPayload)))); + const updated = getVariable(identifier, getState()); + dispatch(toKeyedAction(key, hideOptions())); if (getCurrentText(existing) === getCurrentText(updated)) { return; @@ -93,36 +100,43 @@ export const commitChangesToVariable = (callback?: (updated: any) => void): Thun }; export const openOptions = - ({ id }: VariableIdentifier, callback?: (updated: any) => void): ThunkResult => + (identifier: KeyedVariableIdentifier, callback?: (updated: any) => void): ThunkResult => async (dispatch, getState) => { - const picker = getState().templating.optionsPicker; + const { id, rootStateKey: uid } = identifier; + const picker = getVariablesState(uid, getState()).optionsPicker; if (picker.id && picker.id !== id) { - await dispatch(commitChangesToVariable(callback)); + await dispatch(commitChangesToVariable(uid, callback)); } - const variable = getVariable(id, getState()); - dispatch(showOptions(variable)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(uid, showOptions(variable))); }; -export const toggleOptionByHighlight = (clearOthers: boolean, forceSelect = false): ThunkResult => { +export const toggleOptionByHighlight = (key: string, clearOthers: boolean, forceSelect = false): ThunkResult => { return (dispatch, getState) => { - const { highlightIndex, options } = getState().templating.optionsPicker; + const { highlightIndex, options } = getVariablesState(key, getState()).optionsPicker; const option = options[highlightIndex]; - dispatch(toggleOption({ option, forceSelect, clearOthers })); + dispatch(toKeyedAction(key, toggleOption({ option, forceSelect, clearOthers }))); }; }; -const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreState, searchQuery: string) => { +const searchForOptions = async ( + dispatch: ThunkDispatch, + getState: () => StoreState, + searchQuery: string, + key: string +) => { try { - const { id } = getState().templating.optionsPicker; - const existing = getVariable(id, getState()); + const { id } = getVariablesState(key, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id, rootStateKey: key, type: 'query' }; + const existing = getVariable(identifier, getState()); const adapter = variableAdapters.get(existing.type); await adapter.updateOptions(existing, searchQuery); - const updated = getVariable(id, getState()); - dispatch(updateOptionsFromSearch(updated.options)); + const updated = getVariable(identifier, getState()); + dispatch(toKeyedAction(key, updateOptionsFromSearch(updated.options))); } catch (error) { console.error(error); } diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts index e28f8a51792..b706a6c3a83 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts @@ -2,7 +2,7 @@ import { cloneDeep } from 'lodash'; import { cleanPickerState, hideOptions, - initialState as optionsPickerInitialState, + initialOptionPickerState as optionsPickerInitialState, moveOptionsHighlight, OPTIONS_LIMIT, optionsPickerReducer, diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.ts index 92d87996682..ce9f009f384 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.ts @@ -21,7 +21,7 @@ export interface OptionsPickerState { multi: boolean; } -export const initialState: OptionsPickerState = { +export const initialOptionPickerState: OptionsPickerState = { id: '', highlightIndex: -1, queryValue: '', @@ -106,7 +106,7 @@ const updateAllSelection = (state: OptionsPickerState): OptionsPickerState => { const optionsPickerSlice = createSlice({ name: 'templating/optionsPicker', - initialState, + initialState: initialOptionPickerState, reducers: { showOptions: (state, action: PayloadAction): OptionsPickerState => { const { query, options } = action.payload; @@ -131,7 +131,7 @@ const optionsPickerSlice = createSlice({ return applyStateChanges(state, updateDefaultSelection, updateOptions); }, hideOptions: (state, action: PayloadAction): OptionsPickerState => { - return { ...initialState }; + return { ...initialOptionPickerState }; }, toggleOption: (state, action: PayloadAction): OptionsPickerState => { const { option, clearOthers, forceSelect } = action.payload; @@ -212,7 +212,7 @@ const optionsPickerSlice = createSlice({ return applyStateChanges(state, updateDefaultSelection, updateOptions); }, - cleanPickerState: () => initialState, + cleanPickerState: () => initialOptionPickerState, }, }); diff --git a/public/app/features/variables/query/QueryVariableEditor.test.tsx b/public/app/features/variables/query/QueryVariableEditor.test.tsx index 69c4cd08810..987446dd904 100644 --- a/public/app/features/variables/query/QueryVariableEditor.test.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.test.tsx @@ -10,14 +10,17 @@ import { LegacyVariableQueryEditor } from '../editor/LegacyVariableQueryEditor'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import { NEW_VARIABLE_ID } from '../constants'; +import { VariableModel } from '../types'; +import { KeyedVariableIdentifier } from '../state/types'; const setupTestContext = (options: Partial) => { + const variableDefaults: Partial = { rootStateKey: 'key' }; const extended = { VariableQueryEditor: LegacyVariableQueryEditor, dataSource: {} as unknown as DataSourceApi, }; const defaults: Props = { - variable: { ...initialQueryVariableModelState }, + variable: { ...initialQueryVariableModelState, ...variableDefaults }, initQueryVariableEditor: jest.fn(), changeQueryVariableDataSource: jest.fn(), changeQueryVariableQuery: jest.fn(), @@ -47,20 +50,22 @@ jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { }; }); +const defaultIdentifier: KeyedVariableIdentifier = { type: 'query', rootStateKey: 'key', id: NEW_VARIABLE_ID }; + describe('QueryVariableEditor', () => { describe('when the component is mounted', () => { it('then it should call initQueryVariableEditor', () => { const { props } = setupTestContext({}); expect(props.initQueryVariableEditor).toHaveBeenCalledTimes(1); - expect(props.initQueryVariableEditor).toHaveBeenCalledWith({ type: 'query', id: NEW_VARIABLE_ID }); + expect(props.initQueryVariableEditor).toHaveBeenCalledWith(defaultIdentifier); }); }); describe('when the user changes', () => { it.each` fieldName | propName | expectedArgs - ${'query'} | ${'changeQueryVariableQuery'} | ${[{ type: 'query', id: NEW_VARIABLE_ID }, 't', 't']} + ${'query'} | ${'changeQueryVariableQuery'} | ${[defaultIdentifier, 't', 't']} ${'regex'} | ${'onPropChange'} | ${[{ propName: 'regex', propValue: 't', updateOptions: true }]} `( '$fieldName field and tabs away then $propName should be called with correct args', diff --git a/public/app/features/variables/query/QueryVariableEditor.tsx b/public/app/features/variables/query/QueryVariableEditor.tsx index 521748f237f..94ff3cb539b 100644 --- a/public/app/features/variables/query/QueryVariableEditor.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.tsx @@ -9,9 +9,9 @@ import { DataSourceInstanceSettings, getDataSourceRef, LoadingState, SelectableV import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../types'; import { changeQueryVariableDataSource, changeQueryVariableQuery, initQueryVariableEditor } from './actions'; +import { initialVariableEditorState } from '../editor/reducer'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { StoreState } from '../../../types'; -import { toVariableIdentifier } from '../state/types'; import { changeVariableMultiValue } from '../state/actions'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { isLegacyQueryEditor, isQueryEditor } from '../guard'; @@ -20,10 +20,24 @@ import { VariableTextField } from '../editor/VariableTextField'; import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect'; import { QueryVariableSortSelect } from './QueryVariableSortSelect'; import { getQueryVariableEditorState } from '../editor/selectors'; +import { getVariablesState } from '../state/selectors'; +import { toKeyedVariableIdentifier } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - extended: getQueryVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + if (!rootStateKey) { + console.error('QueryVariableEditor: variable has no rootStateKey'); + return { + extended: getQueryVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + + return { + extended: getQueryVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initQueryVariableEditor, @@ -52,13 +66,13 @@ export class QueryVariableEditorUnConnected extends PureComponent }; async componentDidMount() { - await this.props.initQueryVariableEditor(toVariableIdentifier(this.props.variable)); + await this.props.initQueryVariableEditor(toKeyedVariableIdentifier(this.props.variable)); } componentDidUpdate(prevProps: Readonly): void { if (prevProps.variable.datasource !== this.props.variable.datasource) { this.props.changeQueryVariableDataSource( - toVariableIdentifier(this.props.variable), + toKeyedVariableIdentifier(this.props.variable), this.props.variable.datasource ); } @@ -73,7 +87,7 @@ export class QueryVariableEditorUnConnected extends PureComponent onLegacyQueryChange = async (query: any, definition: string) => { if (this.props.variable.query !== query) { - this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition); + this.props.changeQueryVariableQuery(toKeyedVariableIdentifier(this.props.variable), query, definition); } }; @@ -85,7 +99,7 @@ export class QueryVariableEditorUnConnected extends PureComponent definition = query.query; } - this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition); + this.props.changeQueryVariableQuery(toKeyedVariableIdentifier(this.props.variable), query, definition); } }; diff --git a/public/app/features/variables/query/VariableQueryRunner.test.ts b/public/app/features/variables/query/VariableQueryRunner.test.ts index e212b625f0c..e1670479de2 100644 --- a/public/app/features/variables/query/VariableQueryRunner.test.ts +++ b/public/app/features/variables/query/VariableQueryRunner.test.ts @@ -5,9 +5,13 @@ import { delay } from 'rxjs/operators'; import { UpdateOptionsResults, VariableQueryRunner } from './VariableQueryRunner'; import { queryBuilder } from '../shared/testing/builders'; import { QueryRunner, QueryRunners } from './queryRunners'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { QueryVariableModel } from '../types'; import { updateVariableOptions } from './reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { initialTransactionState } from '../state/transactionReducer'; +import { getPreloadedState } from '../state/helpers'; +import { toKeyedVariableIdentifier } from '../utils'; type DoneCallback = { (...args: any[]): any; @@ -16,7 +20,7 @@ type DoneCallback = { function expectOnResults(args: { runner: VariableQueryRunner; - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; done: DoneCallback; expect: (results: UpdateOptionsResults[]) => void; }) { @@ -32,7 +36,7 @@ function expectOnResults(args: { done(); } catch (err) { subscription.unsubscribe(); - done.fail(err); + done(err); } } }, @@ -40,25 +44,23 @@ function expectOnResults(args: { } function getTestContext(variable?: QueryVariableModel) { - variable = variable ?? queryBuilder().withId('query').build(); const getTimeSrv = jest.fn().mockReturnValue({ timeRange: jest.fn().mockReturnValue(getDefaultTimeRange()), }); + const key = '0123456789'; + variable = variable ?? queryBuilder().withId('query').withRootStateKey(key).withName('query').build(); const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([]) }; - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); const searchFilter = undefined; const getTemplatedRegex = jest.fn().mockReturnValue('getTemplatedRegex result'); const dispatch = jest.fn().mockResolvedValue({}); - const getState = jest.fn().mockReturnValue({ - templating: { - transaction: { - uid: '0123456789', - }, - }, + const templatingState = { + transaction: { ...initialTransactionState, uid: key }, variables: { [variable.id]: variable, }, - }); + }; + const getState = jest.fn().mockReturnValue(getPreloadedState(key, templatingState)); const queryRunner: QueryRunner = { type: VariableSupportType.Standard, canRun: jest.fn().mockReturnValue(true), @@ -81,6 +83,7 @@ function getTestContext(variable?: QueryVariableModel) { }); return { + key, identifier, datasource, runner, @@ -100,7 +103,7 @@ function getTestContext(variable?: QueryVariableModel) { describe('VariableQueryRunner', () => { describe('happy case', () => { it('then it should work as expected', (done) => { - const { identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } = + const { key, identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } = getTestContext(); expectOnResults({ @@ -124,11 +127,14 @@ describe('VariableQueryRunner', () => { // updateVariableOptions and validateVariableSelectionState expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - updateVariableOptions({ - id: 'query', - type: 'query', - data: { results: [], templatedRegex: 'getTemplatedRegex result' }, - }) + toKeyedAction( + key, + updateVariableOptions({ + id: 'query', + type: 'query', + data: { results: [], templatedRegex: 'getTemplatedRegex result' }, + }) + ) ); }, done, diff --git a/public/app/features/variables/query/VariableQueryRunner.ts b/public/app/features/variables/query/VariableQueryRunner.ts index ea17c2ce2ea..866cc92ca85 100644 --- a/public/app/features/variables/query/VariableQueryRunner.ts +++ b/public/app/features/variables/query/VariableQueryRunner.ts @@ -11,8 +11,8 @@ import { ScopedVars, } from '@grafana/data'; -import { VariableIdentifier } from '../state/types'; -import { getVariable } from '../state/selectors'; +import { KeyedVariableIdentifier } from '../state/types'; +import { getLastKey, getVariable } from '../state/selectors'; import { QueryVariableModel, VariableRefresh } from '../types'; import { StoreState, ThunkDispatch } from '../../../types'; import { dispatch, getState } from '../../../store/store'; @@ -24,14 +24,14 @@ import { runRequest } from '../../query/state/runRequest'; import { toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators'; interface UpdateOptionsArgs { - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; datasource: DataSourceApi; searchFilter?: string; } export interface UpdateOptionsResults { state: LoadingState; - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; error?: any; cancelled?: boolean; } @@ -49,7 +49,7 @@ interface VariableQueryRunnerArgs { export class VariableQueryRunner { private readonly updateOptionsRequests: Subject; private readonly updateOptionsResults: Subject; - private readonly cancelRequests: Subject<{ identifier: VariableIdentifier }>; + private readonly cancelRequests: Subject<{ identifier: KeyedVariableIdentifier }>; private readonly subscription: Unsubscribable; constructor( @@ -65,7 +65,7 @@ export class VariableQueryRunner { ) { this.updateOptionsRequests = new Subject(); this.updateOptionsResults = new Subject(); - this.cancelRequests = new Subject<{ identifier: VariableIdentifier }>(); + this.cancelRequests = new Subject<{ identifier: KeyedVariableIdentifier }>(); this.onNewRequest = this.onNewRequest.bind(this); this.subscription = this.updateOptionsRequests.subscribe(this.onNewRequest); } @@ -74,11 +74,11 @@ export class VariableQueryRunner { this.updateOptionsRequests.next(args); } - getResponse(identifier: VariableIdentifier): Observable { + getResponse(identifier: KeyedVariableIdentifier): Observable { return this.updateOptionsResults.asObservable().pipe(filter((result) => result.identifier === identifier)); } - cancelRequest(identifier: VariableIdentifier): void { + cancelRequest(identifier: KeyedVariableIdentifier): void { this.cancelRequests.next({ identifier }); } @@ -99,11 +99,11 @@ export class VariableQueryRunner { getState, } = this.dependencies; - const beforeUid = getState().templating.transaction.uid; + const beforeKey = getLastKey(getState()); this.updateOptionsResults.next({ identifier, state: LoadingState.Loading }); - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); const timeSrv = getTimeSrv(); const runnerArgs = { variable, datasource, searchFilter, timeSrv, runRequest }; const runner = queryRunners.getRunnerForDatasource(datasource); @@ -115,9 +115,9 @@ export class VariableQueryRunner { .pipe( 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; + const afterKey = getLastKey(getState()); - return beforeUid === afterUid; + return beforeKey === afterKey; }), 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 diff --git a/public/app/features/variables/query/actions.test.tsx b/public/app/features/variables/query/actions.test.tsx index 03623609ebb..7f8afffcd8b 100644 --- a/public/app/features/variables/query/actions.test.tsx +++ b/public/app/features/variables/query/actions.test.tsx @@ -4,9 +4,8 @@ import { DataSourceRef, getDefaultTimeRange, LoadingState } from '@grafana/data' import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from '../state/helpers'; +import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers'; import { QueryVariableModel, VariableHide, VariableQueryEditorProps, VariableRefresh, VariableSort } from '../types'; -import { toVariablePayload } from '../state/types'; import { addVariable, changeVariableProp, @@ -27,6 +26,7 @@ import { updateVariableOptions } from './reducer'; import { addVariableEditorError, changeVariableEditorExtended, + initialVariableEditorState, removeVariableEditorError, setIdInEditor, } from '../editor/reducer'; @@ -40,6 +40,8 @@ import { setVariableQueryRunner, VariableQueryRunner } from './VariableQueryRunn import { setDataSourceSrv } from '@grafana/runtime'; import { variablesInitTransaction } from '../state/transactionReducer'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; const mocks: Record = { datasource: { @@ -93,8 +95,10 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenNoActionsWhereDispatched(); }); @@ -109,16 +113,18 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption('A'); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); @@ -132,21 +138,19 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [updateOptions, setCurrentAction] = actions; - const expectedNumberOfActions = 2; - - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) + ); }); }); @@ -159,23 +163,21 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [clearErrors, updateOptions, setCurrentAction] = actions; - const expectedNumberOfActions = 3; - - expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) + ); }); }); @@ -188,21 +190,19 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), 'search'), true); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [clearErrors, updateOptions] = actions; - const expectedNumberOfActions = 2; - - expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))) + ); }); }); @@ -216,19 +216,26 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { const expectedNumberOfActions = 5; - expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(variable))); - expect(dispatchedActions[1]).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(dispatchedActions[2]).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message })); + expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(variable)))); + expect(dispatchedActions[1]).toEqual(toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' }))); + expect(dispatchedActions[2]).toEqual( + toKeyedAction('key', addVariableEditorError({ errorProp: 'update', errorText: error.message })) + ); expect(dispatchedActions[3]).toEqual( - variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } })) + toKeyedAction( + 'key', + variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } })) + ) ); expect(dispatchedActions[4].type).toEqual(notifyApp.type); expect(dispatchedActions[4].payload.title).toEqual('Templating [0]'); @@ -253,12 +260,17 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -276,12 +288,17 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -298,12 +315,17 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -319,15 +341,20 @@ 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( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); @@ -335,7 +362,14 @@ describe('query actions', () => { it('then correct actions are dispatched', async () => { const variable = createVariable({ datasource: { uid: 'other' } }); const editor = mocks.VariableQueryEditor; - const preloadedState: any = { templating: { editor: { extended: { dataSource: { type: 'previous' } } } } }; + const previousDataSource: any = { type: 'previous' }; + const templatingState = { + editor: { + ...initialVariableEditorState, + extended: { dataSource: previousDataSource, VariableQueryEditor: editor }, + }, + }; + const preloadedState = getPreloadedState('key', templatingState); mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({ components: { VariableQueryEditor: editor }, @@ -344,17 +378,20 @@ describe('query actions', () => { const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })) + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) ) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: '' })), - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: '' }))), + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -371,15 +408,20 @@ 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( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -396,21 +438,29 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -427,21 +477,29 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -457,21 +515,29 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption('A'); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -484,19 +550,20 @@ 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); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const errorText = 'Query cannot contain a reference to itself. Variable: $' + variable.name; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [editorError] = actions; - const expectedNumberOfActions = 1; - - expect(editorError).toEqual(addVariableEditorError({ errorProp: 'query', errorText })); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', addVariableEditorError({ errorProp: 'query', errorText })) + ); }); }); @@ -672,6 +739,7 @@ function createVariable(extend?: Partial): QueryVariableMode return { type: 'query', id: '0', + rootStateKey: 'key', global: false, current: createOption(''), options: [], diff --git a/public/app/features/variables/query/actions.ts b/public/app/features/variables/query/actions.ts index 8c9f9c0fb6f..45d42c5fec8 100644 --- a/public/app/features/variables/query/actions.ts +++ b/public/app/features/variables/query/actions.ts @@ -5,30 +5,32 @@ import { DataSourceRef } from '@grafana/data'; import { updateOptions } from '../state/actions'; import { QueryVariableModel } from '../types'; import { ThunkResult } from '../../../types'; -import { getVariable } from '../state/selectors'; +import { getVariable, getVariablesState } from '../state/selectors'; import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer'; import { changeVariableProp } from '../state/sharedReducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { getVariableQueryEditor } from '../editor/getVariableQueryEditor'; import { getVariableQueryRunner } from './VariableQueryRunner'; import { variableQueryObserver } from './variableQueryObserver'; -import { hasOngoingTransaction } from '../utils'; +import { hasOngoingTransaction, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; import { getQueryVariableEditorState } from '../editor/selectors'; export const updateQueryVariableOptions = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, searchFilter?: string ): ThunkResult => { return async (dispatch, getState) => { try { - if (!hasOngoingTransaction(getState())) { + const { rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, 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' })); + const variableInState = getVariable(identifier, getState()); + if (getVariablesState(rootStateKey, getState()).editor.id === variableInState.id) { + dispatch(toKeyedAction(rootStateKey, removeVariableEditorError({ errorProp: 'update' }))); } const datasource = await getDataSourceSrv().get(variableInState.datasource ?? ''); @@ -44,8 +46,11 @@ export const updateQueryVariableOptions = ( }); } catch (err) { const error = toDataQueryError(err); - if (getState().templating.editor.id === identifier.id) { - dispatch(addVariableEditorError({ errorProp: 'update', errorText: error.message })); + const { rootStateKey } = identifier; + if (getVariablesState(rootStateKey, getState()).editor.id === identifier.id) { + dispatch( + toKeyedAction(rootStateKey, addVariableEditorError({ errorProp: 'update', errorText: error.message })) + ); } throw error; @@ -54,33 +59,43 @@ export const updateQueryVariableOptions = ( }; export const initQueryVariableEditor = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => async (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); - await dispatch(changeQueryVariableDataSource(toVariableIdentifier(variable), variable.datasource)); + const variable = getVariable(identifier, getState()); + await dispatch(changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), variable.datasource)); }; export const changeQueryVariableDataSource = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, name: DataSourceRef | null ): ThunkResult => { return async (dispatch, getState) => { try { - const extendedEditorState = getQueryVariableEditorState(getState().templating.editor); + const { rootStateKey } = identifier; + const { editor } = getVariablesState(rootStateKey, getState()); + const extendedEditorState = getQueryVariableEditorState(editor); const previousDatasource = extendedEditorState?.dataSource; const dataSource = await getDataSourceSrv().get(name ?? ''); if (previousDatasource && previousDatasource.type !== dataSource?.type) { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: '' }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: '' })) + ) + ); } const VariableQueryEditor = await getVariableQueryEditor(dataSource); dispatch( - changeVariableEditorExtended({ - dataSource: dataSource, - VariableQueryEditor: VariableQueryEditor, - }) + toKeyedAction( + rootStateKey, + changeVariableEditorExtended({ + dataSource, + VariableQueryEditor, + }) + ) ); } catch (err) { console.error(err); @@ -89,22 +104,38 @@ export const changeQueryVariableDataSource = ( }; export const changeQueryVariableQuery = - (identifier: VariableIdentifier, query: any, definition?: string): ThunkResult => + (identifier: KeyedVariableIdentifier, query: any, definition?: string): ThunkResult => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, getState()); if (hasSelfReferencingQuery(variableInState.name, query)) { const errorText = 'Query cannot contain a reference to itself. Variable: $' + variableInState.name; - dispatch(addVariableEditorError({ errorProp: 'query', errorText })); + dispatch(toKeyedAction(rootStateKey, addVariableEditorError({ errorProp: 'query', errorText }))); return; } - dispatch(removeVariableEditorError({ errorProp: 'query' })); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); + dispatch(toKeyedAction(rootStateKey, removeVariableEditorError({ errorProp: 'query' }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })) + ) + ); if (definition) { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition })) + ) + ); } else if (typeof query === 'string') { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: query }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: query })) + ) + ); } await dispatch(updateOptions(identifier)); diff --git a/public/app/features/variables/query/adapter.ts b/public/app/features/variables/query/adapter.ts index 7edd533e685..06a2442ab69 100644 --- a/public/app/features/variables/query/adapter.ts +++ b/public/app/features/variables/query/adapter.ts @@ -7,8 +7,7 @@ import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { QueryVariableEditor } from './QueryVariableEditor'; import { updateQueryVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; -import { containsVariable, isAllVariable } from '../utils'; +import { containsVariable, isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -25,16 +24,16 @@ export const createQueryVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable, searchFilter) => { - await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter)); + await dispatch(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), searchFilter)); }, getSaveModel: (variable) => { - const { index, id, state, global, queryValue, ...rest } = cloneDeep(variable); + const { index, id, state, global, queryValue, rootStateKey, ...rest } = cloneDeep(variable); // remove options if (variable.refresh !== VariableRefresh.never) { return { ...rest, options: [] }; diff --git a/public/app/features/variables/query/operators.test.ts b/public/app/features/variables/query/operators.test.ts index fa7eba38e49..ad3cdcce819 100644 --- a/public/app/features/variables/query/operators.test.ts +++ b/public/app/features/variables/query/operators.test.ts @@ -3,6 +3,7 @@ import { queryBuilder } from '../shared/testing/builders'; import { FieldType, toDataFrame } from '@grafana/data'; import { updateVariableOptions } from './reducer'; import { areMetricFindValues, toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; describe('operators', () => { beforeEach(() => { @@ -12,7 +13,7 @@ describe('operators', () => { describe('validateVariableSelection', () => { describe('when called', () => { it('then the correct observable should be created', async () => { - const variable = queryBuilder().withId('query').build(); + const variable = queryBuilder().withId('query').withRootStateKey('key').build(); const dispatch = jest.fn().mockResolvedValue({}); const observable = of(undefined).pipe(validateVariableSelection({ variable, dispatch })); @@ -27,7 +28,7 @@ describe('operators', () => { describe('updateOptionsState', () => { describe('when called', () => { it('then the correct observable should be created', async () => { - const variable = queryBuilder().withId('query').build(); + const variable = queryBuilder().withId('query').withRootStateKey('key').build(); const dispatch = jest.fn(); const getTemplatedRegexFunc = jest.fn().mockReturnValue('getTemplatedRegexFunc result'); @@ -39,11 +40,14 @@ describe('operators', () => { expect(getTemplatedRegexFunc).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith( - updateVariableOptions({ - id: 'query', - type: 'query', - data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, - }) + toKeyedAction( + 'key', + updateVariableOptions({ + id: 'query', + type: 'query', + data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, + }) + ) ); }); }); diff --git a/public/app/features/variables/query/operators.ts b/public/app/features/variables/query/operators.ts index 3f57960c529..2085df4bd23 100644 --- a/public/app/features/variables/query/operators.ts +++ b/public/app/features/variables/query/operators.ts @@ -3,12 +3,12 @@ import { map, mergeMap } from 'rxjs/operators'; import { QueryVariableModel } from '../types'; import { ThunkDispatch } from '../../../types'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { validateVariableSelectionState } from '../state/actions'; import { FieldType, getFieldDisplayName, isDataFrame, MetricFindValue, PanelData } from '@grafana/data'; import { updateVariableOptions } from './reducer'; -import { getTemplatedRegex } from '../utils'; +import { getTemplatedRegex, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; import { getProcessedDataFrames } from 'app/features/query/state/runRequest'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; export function toMetricFindValues(): OperatorFunction { return (source) => @@ -102,9 +102,13 @@ export function updateOptionsState(args: { source.pipe( map((results) => { const { variable, dispatch, getTemplatedRegexFunc } = args; + if (!variable.rootStateKey) { + console.error('updateOptionsState: variable.rootStateKey is not defined'); + return; + } const templatedRegex = getTemplatedRegexFunc(variable); const payload = toVariablePayload(variable, { results, templatedRegex }); - dispatch(updateVariableOptions(payload)); + dispatch(toKeyedAction(variable.rootStateKey, updateVariableOptions(payload))); }) ); } @@ -124,7 +128,7 @@ export function validateVariableSelection(args: { // So after search and selection the current value is already update so no setValue, refresh and URL update is performed // The if statement below fixes https://github.com/grafana/grafana/issues/25671 if (!searchFilter) { - return from(dispatch(validateVariableSelectionState(toVariableIdentifier(variable)))); + return from(dispatch(validateVariableSelectionState(toKeyedVariableIdentifier(variable)))); } return of(); diff --git a/public/app/features/variables/query/reducer.test.ts b/public/app/features/variables/query/reducer.test.ts index dd143490b2c..106f8c50903 100644 --- a/public/app/features/variables/query/reducer.test.ts +++ b/public/app/features/variables/query/reducer.test.ts @@ -1,3 +1,6 @@ +import { cloneDeep } from 'lodash'; +import { MetricFindValue } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { metricNamesToVariableValues, @@ -6,11 +9,10 @@ import { updateVariableOptions, } from './reducer'; import { QueryVariableModel, VariableSort } from '../types'; -import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createQueryVariableAdapter } from './adapter'; -import { MetricFindValue } from '@grafana/data'; +import { toVariablePayload } from '../utils'; describe('queryVariableReducer', () => { const adapter = createQueryVariableAdapter(); diff --git a/public/app/features/variables/query/reducer.ts b/public/app/features/variables/query/reducer.ts index 03e2e0d89e8..e4afc5c609d 100644 --- a/public/app/features/variables/query/reducer.ts +++ b/public/app/features/variables/query/reducer.ts @@ -4,8 +4,9 @@ import { MetricFindValue, stringToJsRegex } from '@grafana/data'; import { initialVariableModelState, QueryVariableModel, VariableOption, VariableRefresh, VariableSort } from '../types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NONE_VARIABLE_TEXT, NONE_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; interface VariableOptionsUpdate { templatedRegex: string; diff --git a/public/app/features/variables/query/variableQueryObserver.test.ts b/public/app/features/variables/query/variableQueryObserver.test.ts index 7b2e41fc8c8..8232e74df55 100644 --- a/public/app/features/variables/query/variableQueryObserver.test.ts +++ b/public/app/features/variables/query/variableQueryObserver.test.ts @@ -1,6 +1,6 @@ import { variableQueryObserver } from './variableQueryObserver'; import { LoadingState } from '@grafana/data'; -import { VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { UpdateOptionsResults } from './VariableQueryRunner'; function getTestContext(args: { next?: UpdateOptionsResults; error?: any; complete?: boolean }) { @@ -27,7 +27,7 @@ function getTestContext(args: { next?: UpdateOptionsResults; error?: any; comple return { resolve, reject, subscription, observer }; } -const identifier: VariableIdentifier = { id: 'id', type: 'query' }; +const identifier: KeyedVariableIdentifier = { id: 'id', type: 'query', rootStateKey: 'uid' }; describe('variableQueryObserver', () => { describe('when receiving a Done state', () => { diff --git a/public/app/features/variables/shared/testing/variableBuilder.ts b/public/app/features/variables/shared/testing/variableBuilder.ts index a1bae77fc59..f966605787b 100644 --- a/public/app/features/variables/shared/testing/variableBuilder.ts +++ b/public/app/features/variables/shared/testing/variableBuilder.ts @@ -19,6 +19,11 @@ export class VariableBuilder { return this; } + withRootStateKey(key: string) { + this.variable.rootStateKey = key; + return this; + } + build(): T { return this.variable; } diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index 1b90b4d77ce..81b9d40b130 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,13 +1,12 @@ import { AnyAction } from 'redux'; -import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; +import { getPreloadedState, getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; import { createTextBoxVariableAdapter } from '../textbox/adapter'; import { createConstantVariableAdapter } from '../constant/adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { cancelVariables, changeVariableMultiValue, @@ -27,7 +26,6 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { constantBuilder, customBuilder, @@ -52,6 +50,8 @@ import * as runtime from '@grafana/runtime'; import { LoadingState } from '@grafana/data'; import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelpers'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NEW_VARIABLE_ID } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -81,44 +81,66 @@ runtime.setDataSourceSrv({ describe('shared actions', () => { describe('when initDashboardTemplating is dispatched', () => { it('then correct actions are dispatched', () => { + const key = 'key'; const query = queryBuilder().build(); const constant = constantBuilder().build(); const datasource = datasourceBuilder().build(); const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; + const dashboard: any = { templating: { list } }; - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(initDashboardTemplating(list)) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) .thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions.length).toEqual(8); expect(dispatchedActions[0]).toEqual( - addVariable(toVariablePayload(query, { global: false, index: 0, model: query })) + toKeyedAction(key, addVariable(toVariablePayload(query, { global: false, index: 0, model: query }))) ); expect(dispatchedActions[1]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) ); expect(dispatchedActions[2]).toEqual( - addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom })) + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom }))) ); expect(dispatchedActions[3]).toEqual( - addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox }))) ); // because uuid are dynamic we need to get the uuid from the resulting state // an alternative would be to add our own uuids in the model above instead expect(dispatchedActions[4]).toEqual( - variableStateNotStarted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...query, id: dispatchedActions[4].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[5]).toEqual( - variableStateNotStarted(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...constant, id: dispatchedActions[5].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[6]).toEqual( - variableStateNotStarted(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...custom, id: dispatchedActions[6].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[7]).toEqual( - variableStateNotStarted(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.action.payload.id }) + ) + ) ); return true; @@ -128,52 +150,71 @@ describe('shared actions', () => { describe('when processVariables is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const query = queryBuilder().build(); const constant = constantBuilder().build(); const datasource = datasourceBuilder().build(); const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; - const preloadedState = { - templating: {} as unknown as TemplatingState, - }; + const dashboard: any = { templating: { list } }; + const preloadedState = getPreloadedState(key, {}); const locationService: any = { getSearchObject: () => ({}) }; runtime.setLocationService(locationService); const variableQueryRunner: any = { cancelRequest: jest.fn(), queueRequest: jest.fn(), - getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: toVariableIdentifier(query) }), + getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: toKeyedVariableIdentifier(query) }), destroy: jest.fn(), }; setVariableQueryRunner(variableQueryRunner); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariables(), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariables(key), true); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions.length).toEqual(5); expect(dispatchedActions[0]).toEqual( - variableStateFetching(toVariablePayload({ ...query, id: dispatchedActions[0].payload.id })) + toKeyedAction( + key, + variableStateFetching(toVariablePayload({ ...query, id: dispatchedActions[0].payload.action.payload.id })) + ) ); expect(dispatchedActions[1]).toEqual( - variableStateCompleted(toVariablePayload({ ...constant, id: dispatchedActions[1].payload.id })) + toKeyedAction( + key, + variableStateCompleted( + toVariablePayload({ ...constant, id: dispatchedActions[1].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[2]).toEqual( - variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.id })) + toKeyedAction( + key, + variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.action.payload.id })) + ) ); expect(dispatchedActions[3]).toEqual( - variableStateCompleted(toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.id })) + toKeyedAction( + key, + variableStateCompleted( + toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[4]).toEqual( - variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) + toKeyedAction( + key, + variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.action.payload.id })) + ) ); return true; @@ -183,8 +224,10 @@ describe('shared actions', () => { // Fix for https://github.com/grafana/grafana/issues/28791 it('fix for https://github.com/grafana/grafana/issues/28791', async () => { setVariableQueryRunner(new VariableQueryRunner()); + const key = 'key'; const stats = queryBuilder() .withId('stats') + .withRootStateKey(key) .withName('stats') .withQuery('stats.*') .withRefresh(VariableRefresh.onDashboardLoad) @@ -195,6 +238,7 @@ describe('shared actions', () => { const substats = queryBuilder() .withId('substats') + .withRootStateKey(key) .withName('substats') .withQuery('stats.$stats.*') .withRefresh(VariableRefresh.onDashboardLoad) @@ -204,45 +248,64 @@ describe('shared actions', () => { .build(); const list = [stats, substats]; + const dashboard: any = { templating: { list } }; const query = { orgId: '1', 'var-stats': 'response', 'var-substats': ALL_VARIABLE_TEXT }; const locationService: any = { getSearchObject: () => query }; runtime.setLocationService(locationService); - const preloadedState = { - templating: {} as unknown as TemplatingState, - }; + const preloadedState = getPreloadedState(key, {}); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariables(), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariables(key), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload(stats)), - updateVariableOptions( - toVariablePayload(stats, { results: [{ text: 'responses' }, { text: 'timers' }], templatedRegex: '' }) + toKeyedAction(key, variableStateFetching(toVariablePayload(stats))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload(stats, { results: [{ text: 'responses' }, { text: 'timers' }], templatedRegex: '' }) + ) ), - setCurrentVariableValue( - toVariablePayload(stats, { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(stats, { + option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false }, + }) + ) ), - variableStateCompleted(toVariablePayload(stats)), - setCurrentVariableValue( - toVariablePayload(stats, { option: { text: ['response'], value: ['response'], selected: false } }) + toKeyedAction(key, variableStateCompleted(toVariablePayload(stats))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(stats, { option: { text: ['response'], value: ['response'], selected: false } }) + ) ), - variableStateFetching(toVariablePayload(substats)), - updateVariableOptions( - toVariablePayload(substats, { results: [{ text: '200' }, { text: '500' }], templatedRegex: '' }) + toKeyedAction(key, variableStateFetching(toVariablePayload(substats))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload(substats, { results: [{ text: '200' }, { text: '500' }], templatedRegex: '' }) + ) ), - setCurrentVariableValue( - toVariablePayload(substats, { - option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: true }, - }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(substats, { + option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: true }, + }) + ) ), - variableStateCompleted(toVariablePayload(substats)), - setCurrentVariableValue( - toVariablePayload(substats, { - option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: false }, - }) + toKeyedAction(key, variableStateCompleted(toVariablePayload(substats))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(substats, { + option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: false }, + }) + ) ) ); }); @@ -260,36 +323,42 @@ describe('shared actions', () => { ${undefined} | ${'B'} | ${undefined} | ${'should not dispatch setCurrentVariableValue'} `('then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expected }) => { let custom; - + const key = 'key'; if (!withOptions) { - custom = customBuilder().withId('0').withCurrent(withCurrent).withoutOptions().build(); + custom = customBuilder().withId('0').withRootStateKey(key).withCurrent(withCurrent).withoutOptions().build(); } else { custom = customBuilder() .withId('0') + .withRootStateKey(key) .withOptions(...withOptions) .withCurrent(withCurrent) .build(); } - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) .whenAsyncActionIsDispatched( - validateVariableSelectionState(toVariableIdentifier(custom), defaultValue), + validateVariableSelectionState(toKeyedVariableIdentifier(custom), defaultValue), true ); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - const expectedActions: AnyAction[] = !withOptions - ? [] - : [ - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: expected, value: expected, selected: false } } + const expectedActions: AnyAction[] = withOptions + ? [ + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expected, value: expected, selected: false } } + ) ) ), - ]; + ] + : []; expect(dispatchedActions).toEqual(expectedActions); return true; }); @@ -309,37 +378,49 @@ describe('shared actions', () => { 'then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expectedText, expectedSelected }) => { let custom; - + const key = 'key'; if (!withOptions) { - custom = customBuilder().withId('0').withMulti().withCurrent(withCurrent).withoutOptions().build(); + custom = customBuilder() + .withId('0') + .withRootStateKey(key) + .withMulti() + .withCurrent(withCurrent) + .withoutOptions() + .build(); } else { custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti() .withOptions(...withOptions) .withCurrent(withCurrent) .build(); } - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) .whenAsyncActionIsDispatched( - validateVariableSelectionState(toVariableIdentifier(custom), defaultValue), + validateVariableSelectionState(toKeyedVariableIdentifier(custom), defaultValue), true ); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - const expectedActions: AnyAction[] = !withOptions - ? [] - : [ - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: expectedText, value: expectedText, selected: expectedSelected } } + const expectedActions: AnyAction[] = withOptions + ? [ + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expectedText, value: expectedText, selected: expectedSelected } } + ) ) ), - ]; + ] + : []; expect(dispatchedActions).toEqual(expectedActions); return true; }); @@ -351,154 +432,196 @@ describe('shared actions', () => { describe('changeVariableName', () => { describe('when changeVariableName is dispatched with the same name', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), constant.name), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), constant.name), true) .thenDispatchedActionsShouldEqual( - changeVariableNameSucceeded({ type: 'constant', id: 'constant', data: { newName: 'constant' } }) + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant', data: { newName: 'constant' } }) + ) ); }); }); describe('when changeVariableName is dispatched with an unique name', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'constant1'), true) .thenDispatchedActionsShouldEqual( - addVariable({ - type: 'constant', - id: 'constant1', - data: { - global: false, - index: 1, - model: { - ...constant, - name: 'constant1', - id: 'constant1', + toKeyedAction( + key, + addVariable({ + type: 'constant', + id: 'constant1', + data: { global: false, index: 1, - current: { selected: true, text: '', value: '' }, - options: [{ selected: true, text: '', value: '' }], - } as ConstantVariableModel, - }, - }), - changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }), - setIdInEditor({ id: 'constant1' }), - removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } }) + model: { + ...constant, + name: 'constant1', + id: 'constant1', + global: false, + index: 1, + current: { selected: true, text: '', value: '' }, + options: [{ selected: true, text: '', value: '' }], + } as ConstantVariableModel, + }, + }) + ), + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }) + ), + toKeyedAction(key, setIdInEditor({ id: 'constant1' })), + toKeyedAction(key, removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } })) ); }); }); describe('when changeVariableName is dispatched with an unique name for a new variable', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId(NEW_VARIABLE_ID).withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId(NEW_VARIABLE_ID).withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'constant1'), true) .thenDispatchedActionsShouldEqual( - addVariable({ - type: 'constant', - id: 'constant1', - data: { - global: false, - index: 1, - model: { - ...constant, - name: 'constant1', - id: 'constant1', + toKeyedAction( + key, + addVariable({ + type: 'constant', + id: 'constant1', + data: { global: false, index: 1, - current: { selected: true, text: '', value: '' }, - options: [{ selected: true, text: '', value: '' }], - } as ConstantVariableModel, - }, - }), - changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }), - setIdInEditor({ id: 'constant1' }), - removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } }) + model: { + ...constant, + name: 'constant1', + id: 'constant1', + global: false, + index: 1, + current: { selected: true, text: '', value: '' }, + options: [{ selected: true, text: '', value: '' }], + } as ConstantVariableModel, + }, + }) + ), + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }) + ), + toKeyedAction(key, setIdInEditor({ id: 'constant1' })), + toKeyedAction(key, removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } })) ); }); }); describe('when changeVariableName is dispatched with __newName', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '__newName'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), '__newName'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: '__newName', - errorText: "Template names cannot begin with '__', that's reserved for Grafana's global variables", - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: '__newName', + errorText: "Template names cannot begin with '__', that's reserved for Grafana's global variables", + }) + ) ); }); }); describe('when changeVariableName is dispatched with illegal characters', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '#constant!'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), '#constant!'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: '#constant!', - errorText: 'Only word and digit characters are allowed in variable names', - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: '#constant!', + errorText: 'Only word and digit characters are allowed in variable names', + }) + ) ); }); }); describe('when changeVariableName is dispatched with a name that is already used', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'textbox'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'textbox'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: 'textbox', - errorText: 'Variable with the same name already exists', - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: 'textbox', + errorText: 'Variable with the same name already exists', + }) + ) ); }); }); @@ -507,28 +630,42 @@ describe('shared actions', () => { describe('changeVariableMultiValue', () => { describe('when changeVariableMultiValue is dispatched for variable with multi enabled', () => { it('then correct actions are dispatched', () => { - const custom = customBuilder().withId('custom').withMulti(true).withCurrent(['A'], ['A']).build(); + const key = 'key'; + const custom = customBuilder() + .withId('custom') + .withRootStateKey(key) + .withMulti(true) + .withCurrent(['A'], ['A']) + .build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenActionIsDispatched(changeVariableMultiValue(toVariableIdentifier(custom), false), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenActionIsDispatched(changeVariableMultiValue(toKeyedVariableIdentifier(custom), false), true) .thenDispatchedActionsShouldEqual( - changeVariableProp( - toVariablePayload(custom, { - propName: 'multi', - propValue: false, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'multi', + propValue: false, + }) + ) ), - changeVariableProp( - toVariablePayload(custom, { - propName: 'current', - propValue: { - value: 'A', - text: 'A', - selected: true, - }, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'current', + propValue: { + value: 'A', + text: 'A', + selected: true, + }, + }) + ) ) ); }); @@ -536,28 +673,42 @@ describe('shared actions', () => { describe('when changeVariableMultiValue is dispatched for variable with multi disabled', () => { it('then correct actions are dispatched', () => { - const custom = customBuilder().withId('custom').withMulti(false).withCurrent(['A'], ['A']).build(); + const key = 'key'; + const custom = customBuilder() + .withId('custom') + .withRootStateKey(key) + .withMulti(false) + .withCurrent(['A'], ['A']) + .build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenActionIsDispatched(changeVariableMultiValue(toVariableIdentifier(custom), true), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenActionIsDispatched(changeVariableMultiValue(toKeyedVariableIdentifier(custom), true), true) .thenDispatchedActionsShouldEqual( - changeVariableProp( - toVariablePayload(custom, { - propName: 'multi', - propValue: true, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'multi', + propValue: true, + }) + ) ), - changeVariableProp( - toVariablePayload(custom, { - propName: 'current', - propValue: { - value: ['A'], - text: ['A'], - selected: true, - }, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'current', + propValue: { + value: ['A'], + text: ['A'], + selected: true, + }, + }) + ) ) ); }); @@ -567,14 +718,15 @@ describe('shared actions', () => { describe('cleanUpVariables', () => { describe('when called', () => { it('then correct actions are dispatched', async () => { - reduxTester<{ templating: TemplatingState }>() + const key = 'key'; + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(cleanUpVariables()) + .whenActionIsDispatched(cleanUpVariables(key)) .thenDispatchedActionsShouldEqual( - cleanVariables(), - cleanEditorState(), - cleanPickerState(), - variablesClearTransaction() + toKeyedAction(key, cleanVariables()), + toKeyedAction(key, cleanEditorState()), + toKeyedAction(key, cleanPickerState()), + toKeyedAction(key, variablesClearTransaction()) ); }); }); @@ -588,14 +740,15 @@ describe('shared actions', () => { describe('when called', () => { it('then cancelAllInFlightRequests should be called and correct actions are dispatched', async () => { - reduxTester<{ templating: TemplatingState }>() + const key = 'key'; + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(cancelVariables({ getBackendSrv: () => backendSrvMock })) + .whenActionIsDispatched(cancelVariables(key, { getBackendSrv: () => backendSrvMock })) .thenDispatchedActionsShouldEqual( - cleanVariables(), - cleanEditorState(), - cleanPickerState(), - variablesClearTransaction() + toKeyedAction(key, cleanVariables()), + toKeyedAction(key, cleanEditorState()), + toKeyedAction(key, cleanPickerState()), + toKeyedAction(key, variablesClearTransaction()) ); expect(cancelAllInFlightRequestsMock).toHaveBeenCalledTimes(1); diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 4a32665f3d6..b1d57723a8b 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -1,4 +1,3 @@ -import angular from 'angular'; import { castArray, isEqual } from 'lodash'; import { DataQuery, @@ -29,7 +28,7 @@ import { VariableWithOptions, } from '../types'; import { AppNotification, StoreState, ThunkResult } from '../../../types'; -import { getVariable, getVariables } from './selectors'; +import { getIfExistsLastKey, getVariable, getVariablesByKey, getVariablesState } from './selectors'; import { variableAdapters } from '../adapters'; import { Graph } from '../../../core/utils/dag'; import { notifyApp } from 'app/core/actions'; @@ -42,7 +41,7 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types'; +import { KeyedVariableIdentifier } from './types'; import { contextSrv } from 'app/core/services/context_srv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; import { alignCurrentWithMulti } from '../shared/multiOptions'; @@ -72,6 +71,9 @@ import { getCurrentText, getVariableRefresh, hasOngoingTransaction, + toKeyedVariableIdentifier, + toStateKey, + toVariablePayload, } from '../utils'; import { store } from 'app/store/store'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; @@ -81,6 +83,7 @@ import { locationService } from '@grafana/runtime'; import { appEvents } from '../../../core/core'; import { getAllAffectedPanelIdsForVariableChange } from '../inspect/utils'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; // process flow queryVariable // thunk => processVariables @@ -112,24 +115,27 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; // thunk => variableUpdated // adapter => updateOptions for dependent nodes -export const initDashboardTemplating = (list: VariableModel[]): ThunkResult => { +export const initDashboardTemplating = (key: string, dashboard: DashboardModel): ThunkResult => { return (dispatch, getState) => { let orderIndex = 0; + const list = dashboard.templating.list; for (let index = 0; index < list.length; index++) { const model = fixSelectedInconsistency(list[index]); + model.rootStateKey = key; if (!variableAdapters.getIfExists(model.type)) { continue; } - dispatch(addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model }))); + dispatch( + toKeyedAction(key, addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model }))) + ); } getTemplateSrv().updateTimeRange(getTimeSrv().timeRange()); - const variables = getVariables(getState()); - for (let index = 0; index < variables.length; index++) { - const variable = variables[index]; - dispatch(variableStateNotStarted(toVariablePayload(variable))); + const variables = getVariablesByKey(key, getState()); + for (const variable of variables) { + dispatch(toKeyedAction(key, variableStateNotStarted(toVariablePayload(variable)))); } }; }; @@ -160,7 +166,7 @@ export function fixSelectedInconsistency(model: VariableModel): VariableModel | return model; } -export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResult => { +export const addSystemTemplateVariables = (key: string, dashboard: DashboardModel): ThunkResult => { return (dispatch) => { const dashboardModel: DashboardVariableModel = { ...initialVariableModelState, @@ -180,12 +186,15 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable( - toVariablePayload(dashboardModel, { - global: dashboardModel.global, - index: dashboardModel.index, - model: dashboardModel, - }) + toKeyedAction( + key, + addVariable( + toVariablePayload(dashboardModel, { + global: dashboardModel.global, + index: dashboardModel.index, + model: dashboardModel, + }) + ) ) ); @@ -207,7 +216,10 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable(toVariablePayload(orgModel, { global: orgModel.global, index: orgModel.index, model: orgModel })) + toKeyedAction( + key, + addVariable(toVariablePayload(orgModel, { global: orgModel.global, index: orgModel.index, model: orgModel })) + ) ); const userModel: UserVariableModel = { @@ -229,25 +241,39 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable(toVariablePayload(userModel, { global: userModel.global, index: userModel.index, model: userModel })) + toKeyedAction( + key, + addVariable( + toVariablePayload(userModel, { global: userModel.global, index: userModel.index, model: userModel }) + ) + ) ); }; }; -export const changeVariableMultiValue = (identifier: VariableIdentifier, multi: boolean): ThunkResult => { +export const changeVariableMultiValue = (identifier: KeyedVariableIdentifier, multi: boolean): ThunkResult => { return (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); + const { rootStateKey: key } = identifier; + const variable = getVariable(identifier, getState()); const current = alignCurrentWithMulti(variable.current, multi); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'multi', propValue: multi }))); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'current', propValue: current }))); + dispatch( + toKeyedAction(key, changeVariableProp(toVariablePayload(identifier, { propName: 'multi', propValue: multi }))) + ); + dispatch( + toKeyedAction(key, changeVariableProp(toVariablePayload(identifier, { propName: 'current', propValue: current }))) + ); }; }; export const processVariableDependencies = async (variable: VariableModel, state: StoreState) => { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + const dependencies: VariableModel[] = []; - for (const otherVariable of getVariables(state)) { + for (const otherVariable of getVariablesByKey(variable.rootStateKey, state)) { if (variable === otherVariable) { continue; } @@ -259,13 +285,17 @@ export const processVariableDependencies = async (variable: VariableModel, state } } - if (!isWaitingForDependencies(dependencies, state)) { + if (!isWaitingForDependencies(variable.rootStateKey, dependencies, state)) { return; } await new Promise((resolve) => { const unsubscribe = store.subscribe(() => { - if (!isWaitingForDependencies(dependencies, store.getState())) { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + + if (!isWaitingForDependencies(variable.rootStateKey, dependencies, store.getState())) { unsubscribe(); resolve(); } @@ -273,12 +303,12 @@ export const processVariableDependencies = async (variable: VariableModel, state }); }; -const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreState): boolean => { +const isWaitingForDependencies = (key: string, dependencies: VariableModel[], state: StoreState): boolean => { if (dependencies.length === 0) { return false; } - const variables = getVariables(state); + const variables = getVariablesByKey(key, state); const notCompletedDependencies = dependencies.filter((d) => variables.some((v) => v.id === d.id && (v.state === LoadingState.NotStarted || v.state === LoadingState.Loading)) ); @@ -287,11 +317,11 @@ const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreSta }; export const processVariable = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, queryParams: UrlQueryMap ): ThunkResult> => { return async (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); await processVariableDependencies(variable, getState()); const urlValue = queryParams['var-' + variable.name]; @@ -307,7 +337,7 @@ export const processVariable = ( refreshableVariable.refresh === VariableRefresh.onDashboardLoad || refreshableVariable.refresh === VariableRefresh.onTimeRangeChanged ) { - await dispatch(updateOptions(toVariableIdentifier(refreshableVariable))); + await dispatch(updateOptions(toKeyedVariableIdentifier(refreshableVariable))); return; } } @@ -317,11 +347,12 @@ export const processVariable = ( }; }; -export const processVariables = (): ThunkResult> => { +export const processVariables = (key: string): ThunkResult> => { return async (dispatch, getState) => { const queryParams = locationService.getSearchObject(); - const promises = getVariables(getState()).map( - async (variable: VariableModel) => await dispatch(processVariable(toVariableIdentifier(variable), queryParams)) + const promises = getVariablesByKey(key, getState()).map( + async (variable: VariableModel) => + await dispatch(processVariable(toKeyedVariableIdentifier(variable), queryParams)) ); await Promise.all(promises); @@ -329,19 +360,19 @@ export const processVariables = (): ThunkResult> => { }; export const setOptionFromUrl = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, urlValue: UrlQueryValue ): ThunkResult> => { return async (dispatch, getState) => { const stringUrlValue = ensureStringValues(urlValue); - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); if (getVariableRefresh(variable) !== VariableRefresh.never) { // updates options - await dispatch(updateOptions(toVariableIdentifier(variable))); + await dispatch(updateOptions(toKeyedVariableIdentifier(variable))); } // get variable from state - const variableFromState = getVariable(variable.id, getState()); + const variableFromState = getVariable(toKeyedVariableIdentifier(variable), getState()); if (!variableFromState) { throw new Error(`Couldn't find variable with name: ${variable.name}`); } @@ -419,11 +450,11 @@ export const selectOptionsForCurrentValue = (variable: VariableWithOptions): Var }; export const validateVariableSelectionState = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, defaultValue?: string ): ThunkResult> => { return (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); const current = variableInState.current || ({} as unknown as VariableOption); const setValue = variableAdapters.get(variableInState.type).setValue; @@ -475,12 +506,13 @@ export const validateVariableSelectionState = ( }; export const setOptionAsCurrent = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, current: VariableOption, emitChanges: boolean ): ThunkResult> => { return async (dispatch) => { - dispatch(setCurrentVariableValue(toVariablePayload(identifier, { option: current }))); + const { rootStateKey: key } = identifier; + dispatch(toKeyedAction(key, setCurrentVariableValue(toVariablePayload(identifier, { option: current })))); return await dispatch(variableUpdated(identifier, emitChanges)); }; }; @@ -508,25 +540,26 @@ const createGraph = (variables: VariableModel[]) => { }; export const variableUpdated = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, emitChangeEvents: boolean, events: typeof appEvents = appEvents ): ThunkResult> => { return async (dispatch, getState) => { const state = getState(); - const variableInState = getVariable(identifier.id, state); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, state); // if we're initializing variables ignore cascading update because we are in a boot up scenario - if (state.templating.transaction.status === TransactionStatus.Fetching) { + if (getVariablesState(rootStateKey, state).transaction.status === TransactionStatus.Fetching) { if (getVariableRefresh(variableInState) === VariableRefresh.never) { // for variable types with updates that go the setValueFromUrl path in the update let's make sure their state is set to Done. - await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState))); + await dispatch(upgradeLegacyQueries(toKeyedVariableIdentifier(variableInState))); dispatch(completeVariableLoading(identifier)); } return Promise.resolve(); } - const variables = getVariables(state); + const variables = getVariablesByKey(rootStateKey, state); const g = createGraph(variables); const panels = state.dashboard?.getModel()?.panels ?? []; const event: VariablesChangedEvent = isAdHoc(variableInState) @@ -542,14 +575,14 @@ export const variableUpdated = ( return Promise.resolve(); } - return dispatch(updateOptions(toVariableIdentifier(variable))); + return dispatch(updateOptions(toKeyedVariableIdentifier(variable))); }); } return Promise.all(promises).then(() => { if (emitChangeEvents) { events.publish(new VariablesChanged(event)); - locationService.partial(getQueryWithVariables(getState)); + locationService.partial(getQueryWithVariables(rootStateKey, getState)); } }); }; @@ -562,12 +595,13 @@ export interface OnTimeRangeUpdatedDependencies { export const onTimeRangeUpdated = ( + key: string, timeRange: TimeRange, dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv(), events: appEvents } ): ThunkResult> => async (dispatch, getState) => { dependencies.templateSrv.updateTimeRange(timeRange); - const variablesThatNeedRefresh = getVariables(getState()).filter((variable) => { + const variablesThatNeedRefresh = getVariablesByKey(key, getState()).filter((variable) => { if (variable.hasOwnProperty('refresh') && variable.hasOwnProperty('options')) { const variableWithRefresh = variable as unknown as QueryVariableModel; return variableWithRefresh.refresh === VariableRefresh.onTimeRangeChanged; @@ -578,7 +612,7 @@ export const onTimeRangeUpdated = const variableIds = variablesThatNeedRefresh.map((variable) => variable.id); const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) => - dispatch(timeRangeUpdated(toVariableIdentifier(variable))) + dispatch(timeRangeUpdated(toKeyedVariableIdentifier(variable))) ); try { @@ -591,28 +625,28 @@ export const onTimeRangeUpdated = }; const timeRangeUpdated = - (identifier: VariableIdentifier): ThunkResult> => + (identifier: KeyedVariableIdentifier): ThunkResult> => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id); + const variableInState = getVariable(identifier, getState()); const previousOptions = variableInState.options.slice(); - await dispatch(updateOptions(toVariableIdentifier(variableInState), true)); + await dispatch(updateOptions(toKeyedVariableIdentifier(variableInState), true)); - const updatedVariable = getVariable(identifier.id, getState()); + const updatedVariable = getVariable(identifier, getState()); const updatedOptions = updatedVariable.options; - if (angular.toJson(previousOptions) !== angular.toJson(updatedOptions)) { + if (JSON.stringify(previousOptions) !== JSON.stringify(updatedOptions)) { const dashboard = getState().dashboard.getModel(); dashboard?.templateVariableValueUpdated(); } }; export const templateVarsChangedInUrl = - (vars: ExtendedUrlQueryMap, events: typeof appEvents = appEvents): ThunkResult => + (key: string, vars: ExtendedUrlQueryMap, events: typeof appEvents = appEvents): ThunkResult => async (dispatch, getState) => { const update: Array> = []; const dashboard = getState().dashboard.getModel(); - for (const variable of getVariables(getState())) { + for (const variable of getVariablesByKey(key, getState())) { const key = `var-${variable.name}`; if (!vars.hasOwnProperty(key)) { // key not found quick exit @@ -658,7 +692,7 @@ export function isVariableUrlValueDifferentFromCurrent(variable: VariableModel, return !isEqual(variableValue, stringUrlValue); } -const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { +const getQueryWithVariables = (key: string, getState: () => StoreState): UrlQueryMap => { const queryParams = locationService.getSearchObject(); const queryParamsNew = Object.keys(queryParams) @@ -668,7 +702,7 @@ const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { return obj; }, {} as UrlQueryMap); - for (const variable of getVariables(getState())) { + for (const variable of getVariablesByKey(key, getState())) { if (variable.skipUrlSync) { continue; } @@ -681,27 +715,32 @@ const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { }; export const initVariablesTransaction = - (dashboardUid: string, dashboard: DashboardModel): ThunkResult => + (urlUid: string, dashboard: DashboardModel): ThunkResult> => async (dispatch, getState) => { try { - const transactionState = getState().templating.transaction; - if (transactionState.status === TransactionStatus.Fetching) { - // previous dashboard is still fetching variables, cancel all requests - dispatch(cancelVariables()); + const uid = toStateKey(urlUid); + const state = getState(); + const lastKey = getIfExistsLastKey(state); + if (lastKey) { + const transactionState = getVariablesState(lastKey, state).transaction; + if (transactionState.status === TransactionStatus.Fetching) { + // previous dashboard is still fetching variables, cancel all requests + dispatch(cancelVariables(lastKey)); + } } // Start init transaction - dispatch(variablesInitTransaction({ uid: dashboardUid })); + dispatch(toKeyedAction(uid, variablesInitTransaction({ uid }))); // Add system variables like __dashboard and __user - dispatch(addSystemTemplateVariables(dashboard)); + dispatch(addSystemTemplateVariables(uid, dashboard)); // Load all variables into redux store - dispatch(initDashboardTemplating(dashboard.templating.list)); + dispatch(initDashboardTemplating(uid, dashboard)); // Migrate data source name to ref - dispatch(migrateVariablesDatasourceNameToRef()); + dispatch(migrateVariablesDatasourceNameToRef(uid)); // Process all variable updates - await dispatch(processVariables()); - // Mark update as complete - dispatch(variablesCompleteTransaction({ uid: dashboardUid })); + await dispatch(processVariables(uid)); + // Set transaction as complete + dispatch(toKeyedAction(uid, variablesCompleteTransaction({ uid }))); } catch (err) { dispatch(notifyApp(createVariableErrorNotification('Templating init failed', err))); console.error(err); @@ -709,10 +748,11 @@ export const initVariablesTransaction = }; export function migrateVariablesDatasourceNameToRef( - getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv + key: string, + getDatasourceSrvFunc = getDatasourceSrv ): ThunkResult { - return function (dispatch, getState) { - const variables = getVariables(getState()); + return (dispatch, getState) => { + const variables = getVariablesByKey(key, getState()); for (const variable of variables) { if (!isAdHoc(variable) && !isQuery(variable)) { continue; @@ -727,43 +767,51 @@ export function migrateVariablesDatasourceNameToRef( // 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 }))); + const dsRef = ds ? getDataSourceRef(ds) : { uid: nameOrRef }; + dispatch( + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: dsRef })) + ) + ); } }; } -export const cleanUpVariables = (): ThunkResult => (dispatch) => { - dispatch(cleanVariables()); - dispatch(cleanEditorState()); - dispatch(cleanPickerState()); - dispatch(variablesClearTransaction()); -}; +export const cleanUpVariables = + (key: string): ThunkResult => + (dispatch) => { + dispatch(toKeyedAction(key, cleanVariables())); + dispatch(toKeyedAction(key, cleanEditorState())); + dispatch(toKeyedAction(key, cleanPickerState())); + dispatch(toKeyedAction(key, variablesClearTransaction())); + }; type CancelVariablesDependencies = { getBackendSrv: typeof getBackendSrv }; export const cancelVariables = - (dependencies: CancelVariablesDependencies = { getBackendSrv: getBackendSrv }): ThunkResult => + (key: string, dependencies: CancelVariablesDependencies = { getBackendSrv: getBackendSrv }): ThunkResult => (dispatch) => { dependencies.getBackendSrv().cancelAllInFlightRequests(); - dispatch(cleanUpVariables()); + dispatch(cleanUpVariables(key)); }; export const updateOptions = - (identifier: VariableIdentifier, rethrow = false): ThunkResult> => + (identifier: KeyedVariableIdentifier, rethrow = false): ThunkResult> => async (dispatch, getState) => { + const { rootStateKey } = identifier; try { - if (!hasOngoingTransaction(getState())) { + if (!hasOngoingTransaction(rootStateKey, 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))); + const variableInState = getVariable(identifier, getState()); + dispatch(toKeyedAction(rootStateKey, variableStateFetching(toVariablePayload(variableInState)))); + await dispatch(upgradeLegacyQueries(toKeyedVariableIdentifier(variableInState))); await variableAdapters.get(variableInState.type).updateOptions(variableInState); dispatch(completeVariableLoading(identifier)); } catch (error) { - dispatch(variableStateFailed(toVariablePayload(identifier, { error }))); + dispatch(toKeyedAction(rootStateKey, variableStateFailed(toVariablePayload(identifier, { error })))); if (!rethrow) { console.error(error); @@ -779,7 +827,7 @@ export const updateOptions = export const createVariableErrorNotification = ( message: string, error: any, - identifier?: VariableIdentifier + identifier?: KeyedVariableIdentifier ): AppNotification => createErrorNotification( `${identifier ? `Templating [${identifier.id}]` : 'Templating'}`, @@ -787,31 +835,33 @@ export const createVariableErrorNotification = ( ); export const completeVariableLoading = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => (dispatch, getState) => { - if (!hasOngoingTransaction(getState())) { + const { rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); if (variableInState.state !== LoadingState.Done) { - dispatch(variableStateCompleted(toVariablePayload(variableInState))); + dispatch(toKeyedAction(identifier.rootStateKey, variableStateCompleted(toVariablePayload(variableInState)))); } }; export function upgradeLegacyQueries( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv ): ThunkResult { return async function (dispatch, getState) { - if (!hasOngoingTransaction(getState())) { + const { id, rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); if (!isQuery(variable)) { return; @@ -833,11 +883,16 @@ export function upgradeLegacyQueries( } const query = { - refId: `${datasource.name}-${identifier.id}-Variable-Query`, + refId: `${datasource.name}-${id}-Variable-Query`, query: variable.query, }; - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })) + ) + ); } catch (err) { dispatch(notifyApp(createVariableErrorNotification('Failed to upgrade legacy queries', err))); console.error(err); diff --git a/public/app/features/variables/state/helpers.ts b/public/app/features/variables/state/helpers.ts index be9c57493c3..5ea13a64fd9 100644 --- a/public/app/features/variables/state/helpers.ts +++ b/public/app/features/variables/state/helpers.ts @@ -13,9 +13,10 @@ import { import { VariableAdapter } from '../adapters'; import { dashboardReducer } from 'app/features/dashboard/state/reducers'; -import { templatingReducers, TemplatingState } from './reducers'; -import { DashboardState } from '../../../types'; +import { DashboardState, StoreState } from '../../../types'; import { NEW_VARIABLE_ID } from '../constants'; +import { keyedVariablesReducer, KeyedVariablesState } from './keyedVariablesReducer'; +import { getInitialTemplatingState, TemplatingState } from './reducers'; export const getVariableState = ( noOfVariables: number, @@ -86,6 +87,7 @@ export const getVariableState = ( for (let index = 0; index < noOfVariables; index++) { variables[index] = { id: index.toString(), + rootStateKey: 'key', type: 'query', name: `Name-${index}`, hide: VariableHide.dontHide, @@ -102,6 +104,7 @@ export const getVariableState = ( if (includeEmpty) { variables[NEW_VARIABLE_ID] = { id: NEW_VARIABLE_ID, + rootStateKey: 'key', type: 'query', name: `Name-${NEW_VARIABLE_ID}`, hide: VariableHide.dontHide, @@ -122,12 +125,16 @@ export const getVariableTestContext = ( adapter: VariableAdapter, variableOverrides: Partial = {} ) => { - const defaultVariable = { - ...adapter.initialState, + const defaults: Partial = { id: '0', + rootStateKey: 'key', index: 0, name: '0', }; + const defaultVariable = { + ...adapter.initialState, + ...defaults, + }; const initialState: VariablesState = { '0': { ...defaultVariable, ...variableOverrides }, @@ -139,14 +146,31 @@ export const getVariableTestContext = ( export const getRootReducer = () => combineReducers({ dashboard: dashboardReducer, - templating: templatingReducers, + templating: keyedVariablesReducer, }); -export type RootReducerType = { dashboard: DashboardState; templating: TemplatingState }; +export type RootReducerType = { dashboard: DashboardState; templating: KeyedVariablesState }; export const getTemplatingRootReducer = () => combineReducers({ - templating: templatingReducers, + templating: keyedVariablesReducer, }); -export type TemplatingReducerType = { templating: TemplatingState }; +export type TemplatingReducerType = { templating: KeyedVariablesState }; + +export function getPreloadedState( + key: string, + templatingState: Partial +): Pick { + return { + templating: { + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + ...templatingState, + }, + }, + }, + }; +} diff --git a/public/app/features/variables/state/initVariableTransaction.test.ts b/public/app/features/variables/state/initVariableTransaction.test.ts index 5cbede1bfa3..592d46cc68a 100644 --- a/public/app/features/variables/state/initVariableTransaction.test.ts +++ b/public/app/features/variables/state/initVariableTransaction.test.ts @@ -1,4 +1,4 @@ -import { getRootReducer, RootReducerType } from './helpers'; +import { getPreloadedState, getRootReducer, RootReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createConstantVariableAdapter } from '../constant/adapter'; @@ -11,15 +11,15 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariablePayload } from './types'; import { adHocBuilder, constantBuilder, datasourceBuilder, queryBuilder } from '../shared/testing/builders'; -import { cleanEditorState, initialVariableEditorState } from '../editor/reducer'; +import { cleanEditorState } from '../editor/reducer'; import { + initialTransactionState, variablesClearTransaction, variablesCompleteTransaction, variablesInitTransaction, } from './transactionReducer'; -import { cleanPickerState, initialState } from '../pickers/OptionsPicker/reducer'; +import { cleanPickerState } from '../pickers/OptionsPicker/reducer'; import { cleanVariables } from './variablesReducer'; import { createAdHocVariableAdapter } from '../adhoc/adapter'; import { createDataSourceVariableAdapter } from '../datasource/adapter'; @@ -30,6 +30,8 @@ import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelp import { setVariableQueryRunner } from '../query/VariableQueryRunner'; import { createDataSourceOptions } from '../datasource/reducer'; import { initVariablesTransaction } from './actions'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -39,7 +41,7 @@ variableAdapters.setInit(() => [ ]); function getTestContext(variables?: VariableModel[]) { - const uid = 'uid'; + const key = 'key'; const constant = constantBuilder().withId('constant').withName('constant').build(); const templating = { list: variables ?? [constant] }; const getInstanceSettingsMock = jest.fn().mockReturnValue(undefined); @@ -57,34 +59,34 @@ function getTestContext(variables?: VariableModel[]) { }; setVariableQueryRunner(variableQueryRunner); - const dashboard: any = { title: 'Some dash', uid, templating }; + const dashboard: any = { title: 'Some dash', uid: key, templating }; - return { constant, getInstanceSettingsMock, templating, uid, dashboard }; + return { constant, getInstanceSettingsMock, templating, key, 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 { constant, key, dashboard } = getTestContext(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, 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[0]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[1].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[3].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + toKeyedAction(key, 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[5]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(constant)))); + expect(dispatchedActions[6]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(constant)))); - expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[7]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 8; }); }); @@ -92,54 +94,73 @@ describe('initVariablesTransaction', () => { 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 ds = datasourceBuilder().withId('ds').withRootStateKey('key').withName('ds').withQuery('prom').build(); + const query = queryBuilder() + .withId('query') + .withRootStateKey('key') + .withName('query') + .withDatasource(legacyDs) + .build(); + const adhoc = adHocBuilder() + .withId('adhoc') + .withRootStateKey('key') + .withName('adhoc') + .withDatasource(legacyDs) + .build(); + const { key, dashboard } = getTestContext([ds, query, adhoc]); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, 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[0]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[1].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[3].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds })) + toKeyedAction(key, addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds }))) ); expect(dispatchedActions[5]).toEqual( - addVariable(toVariablePayload(query, { global: false, index: 1, model: query })) + toKeyedAction(key, addVariable(toVariablePayload(query, { global: false, index: 1, model: query }))) ); expect(dispatchedActions[6]).toEqual( - addVariable(toVariablePayload(adhoc, { global: false, index: 2, model: adhoc })) + toKeyedAction(key, 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[7]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(ds)))); + expect(dispatchedActions[8]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(query)))); + expect(dispatchedActions[9]).toEqual(toKeyedAction(key, 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: '' } }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) ) ); - 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 })); + expect(dispatchedActions[11]).toEqual( + toKeyedAction( + key, + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) + ); + expect(dispatchedActions[12]).toEqual(toKeyedAction(key, variableStateFetching(toVariablePayload(ds)))); + expect(dispatchedActions[13]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(adhoc)))); + expect(dispatchedActions[14]).toEqual( + toKeyedAction(key, createDataSourceOptions(toVariablePayload(ds, { sources: [], regex: undefined }))) + ); + expect(dispatchedActions[15]).toEqual( + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(ds, { option: { selected: false, text: 'No data sources found', value: '' } }) + ) + ) + ); + expect(dispatchedActions[16]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(ds)))); + expect(dispatchedActions[17]).toEqual(toKeyedAction(key, variableStateFetching(toVariablePayload(query)))); + expect(dispatchedActions[18]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(query)))); + expect(dispatchedActions[19]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 20; }); @@ -149,40 +170,32 @@ describe('initVariablesTransaction', () => { 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 { constant, key, dashboard } = getTestContext(); + const transactionState = { ...initialTransactionState, uid: 'previous-uid', status: TransactionStatus.Fetching }; + const preloadedState = getPreloadedState(key, { transaction: transactionState }); - const tester = await reduxTester({ - preloadedState: { - templating: { - transaction: transactionState, - variables: {}, - optionsPicker: { ...initialState }, - editor: { ...initialVariableEditorState }, - }, - } as unknown as RootReducerType, - }) + const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, 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[0]).toEqual(toKeyedAction(key, cleanVariables())); + expect(dispatchedActions[1]).toEqual(toKeyedAction(key, cleanEditorState())); + expect(dispatchedActions[2]).toEqual(toKeyedAction(key, cleanPickerState())); + expect(dispatchedActions[3]).toEqual(toKeyedAction(key, variablesClearTransaction())); + expect(dispatchedActions[4]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[5].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[5].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[6].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[6].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[7].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[7].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[8]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + toKeyedAction(key, 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 })); + expect(dispatchedActions[9]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(constant)))); + expect(dispatchedActions[10]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(constant)))); + expect(dispatchedActions[11]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 12; }); }); diff --git a/public/app/features/variables/state/keyedVariablesReducer.test.ts b/public/app/features/variables/state/keyedVariablesReducer.test.ts new file mode 100644 index 00000000000..4a4dfa346f5 --- /dev/null +++ b/public/app/features/variables/state/keyedVariablesReducer.test.ts @@ -0,0 +1,128 @@ +import { + initialKeyedVariablesState, + keyedVariablesReducer, + KeyedVariablesState, + toKeyedAction, +} from './keyedVariablesReducer'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { initialTransactionState, variablesCompleteTransaction, variablesInitTransaction } from './transactionReducer'; +import { TransactionStatus } from '../types'; +import { getInitialTemplatingState } from './reducers'; + +describe('dashboardVariablesReducer', () => { + describe('when an toUidAction is dispatched', () => { + it('then state should be correct', () => { + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + }, + }, + }, + }) + .whenActionIsDispatched(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); + + describe('when an toUidAction with variablesInitTransaction is dispatched', () => { + it('then lastUid property should be correct', () => { + const lastUid = 'lastUid'; + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: lastUid, + keys: { + [lastUid]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: lastUid, + status: TransactionStatus.Completed, + }, + }, + }, + }) + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Fetching, + }, + }, + [lastUid]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: lastUid, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); + + describe('when action other than toUidAction is dispatched', () => { + it('then state should not be affected', () => { + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'newUid' })) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); +}); diff --git a/public/app/features/variables/state/keyedVariablesReducer.ts b/public/app/features/variables/state/keyedVariablesReducer.ts new file mode 100644 index 00000000000..c3fd48ed5c6 --- /dev/null +++ b/public/app/features/variables/state/keyedVariablesReducer.ts @@ -0,0 +1,50 @@ +import { AnyAction } from 'redux'; +import { createAction, PayloadAction } from '@reduxjs/toolkit'; +import { getTemplatingReducers, TemplatingState } from './reducers'; +import { variablesInitTransaction } from './transactionReducer'; +import { toStateKey } from '../utils'; + +export interface KeyedVariablesState { + lastKey?: string; + keys: Record; +} + +export const initialKeyedVariablesState: KeyedVariablesState = { keys: {} }; + +export interface KeyedAction { + key: string; + action: PayloadAction; +} + +const keyedAction = createAction('templating/keyedAction'); + +export function toKeyedAction(key: string, action: PayloadAction): PayloadAction { + const keyAsString = toStateKey(key); + return keyedAction({ key: keyAsString, action }); +} + +export function keyedVariablesReducer(state = initialKeyedVariablesState, outerAction: AnyAction): KeyedVariablesState { + if (keyedAction.match(outerAction)) { + const { key, action } = outerAction.payload; + const stringKey = toStateKey(key); + const lastKey = variablesInitTransaction.match(action) ? stringKey : state.lastKey; + const templatingReducers = getTemplatingReducers(); + const prevKeyState = state.keys[stringKey]; + const nextKeyState = templatingReducers(prevKeyState, action); + + return { + ...state, + lastKey, + keys: { + ...state.keys, + [stringKey]: nextKeyState, + }, + }; + } + + return state; +} + +export default { + templating: keyedVariablesReducer, +}; diff --git a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts index f8330b3409c..65cf7a33559 100644 --- a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts +++ b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts @@ -2,13 +2,17 @@ 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'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { getPreloadedState } from './helpers'; +import { toVariablePayload } from '../utils'; 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 key = 'key'; + const query = queryBuilder().withId('query').withRootStateKey(key).withName('query').withDatasource(ds).build(); + const adhoc = adHocBuilder().withId('adhoc').withRootStateKey(key).withName('adhoc').withDatasource(ds).build(); + const templatingState = { variables: { query, adhoc } }; + const state = getPreloadedState(key, templatingState); const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue(state); const getInstanceSettingsMock = jest.fn().mockReturnValue(dsInstance); @@ -18,7 +22,7 @@ function getTestContext(ds: DataSourceRef, dsInstance?: { uid: string; type: str getInstanceSettings: getInstanceSettingsMock, }); - return { query, adhoc, dispatch, getState, getDatasourceSrvFunc }; + return { key, query, adhoc, dispatch, getState, getDatasourceSrvFunc }; } describe('migrateVariablesDatasourceNameToRef', () => { @@ -26,22 +30,34 @@ describe('migrateVariablesDatasourceNameToRef', () => { 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, { + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, { uid: 'a random uid', type: 'prometheus', }); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, 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' } }) + toKeyedAction( + key, + 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' } }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(adhoc, { + propName: 'datasource', + propValue: { uid: 'a random uid', type: 'prometheus' }, + }) + ) ) ); }); @@ -50,16 +66,22 @@ describe('migrateVariablesDatasourceNameToRef', () => { 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); + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, undefined); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) ); expect(dispatch.mock.calls[1][0]).toEqual( - changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) ); }); }); @@ -68,9 +90,9 @@ describe('migrateVariablesDatasourceNameToRef', () => { 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); + const { dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, undefined); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, 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 384f48eb448..388916def33 100644 --- a/public/app/features/variables/state/onTimeRangeUpdated.test.ts +++ b/public/app/features/variables/state/onTimeRangeUpdated.test.ts @@ -10,8 +10,7 @@ import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableRefresh } from '../types'; import { constantBuilder, intervalBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from './helpers'; -import { toVariableIdentifier, toVariablePayload } from './types'; +import { getPreloadedState, getRootReducer, RootReducerType } from './helpers'; import { setCurrentVariableValue, variableStateCompleted, @@ -22,17 +21,20 @@ import { createIntervalOptions } from '../interval/reducer'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; 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'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]); const getTestContext = (dashboard: DashboardModel) => { jest.clearAllMocks(); + const key = 'key'; const interval = intervalBuilder() .withId('interval-0') + .withRootStateKey(key) .withName('interval-0') .withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d') .withCurrent('1m') @@ -41,6 +43,7 @@ const getTestContext = (dashboard: DashboardModel) => { const constant = constantBuilder() .withId('constant-1') + .withRootStateKey(key) .withName('constant-1') .withOptions('a constant') .withCurrent('a constant') @@ -65,17 +68,19 @@ const getTestContext = (dashboard: DashboardModel) => { getModel: () => dashboard, } as unknown as DashboardState; const adapter = variableAdapters.get('interval'); + const templatingState = { + variables: { + 'interval-0': { ...interval }, + 'constant-1': { ...constant }, + }, + }; const preloadedState = { dashboard: dashboardState, - templating: { - variables: { - 'interval-0': { ...interval }, - 'constant-1': { ...constant }, - }, - } as unknown as TemplatingState, + ...getPreloadedState(key, templatingState), } as unknown as RootReducerType; return { + key, interval, range, dependencies, @@ -91,6 +96,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { describe('and options are changed by update', () => { it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, preloadedState, range, dependencies, @@ -101,20 +107,23 @@ describe('when onTimeRangeUpdated is dispatched', () => { const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies)); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies)); tester.thenDispatchedActionsShouldEqual( - variablesInitTransaction({ uid: 'a uid' }), - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), - createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'interval', id: 'interval-0' }, - { option: { text: '1m', value: '1m', selected: false } } + toKeyedAction(key, variablesInitTransaction({ uid: key })), + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction(key, createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'interval', id: 'interval-0' }, + { option: { text: '1m', value: '1m', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); @@ -127,6 +136,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { describe('and options are not changed by update', () => { it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, interval, preloadedState, range, @@ -138,21 +148,26 @@ describe('when onTimeRangeUpdated is dispatched', () => { const base = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(setOptionAsCurrent(toVariableIdentifier(interval), interval.options[0], false)); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched( + setOptionAsCurrent(toKeyedVariableIdentifier(interval), interval.options[0], false) + ); - const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); + const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies), true); tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), - createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'interval', id: 'interval-0' }, - { option: { text: '1m', value: '1m', selected: false } } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction(key, createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'interval', id: 'interval-0' }, + { option: { text: '1m', value: '1m', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); @@ -166,6 +181,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { silenceConsoleOutput(); it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, adapter, preloadedState, range, @@ -179,16 +195,19 @@ describe('when onTimeRangeUpdated is dispatched', () => { const tester = await reduxTester({ preloadedState, debug: true }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions[0]).toEqual( - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(dispatchedActions[1]).toEqual( - variableStateFailed( - toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') }) + toKeyedAction( + key, + variableStateFailed( + toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') }) + ) ) ); expect(dispatchedActions[2].type).toEqual(notifyApp.type); diff --git a/public/app/features/variables/state/processVariable.test.ts b/public/app/features/variables/state/processVariable.test.ts index 9f0c14ab839..d05cfe193fe 100644 --- a/public/app/features/variables/state/processVariable.test.ts +++ b/public/app/features/variables/state/processVariable.test.ts @@ -1,20 +1,20 @@ import { UrlQueryMap } from '@grafana/data'; -import { getTemplatingRootReducer } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { initDashboardTemplating, processVariable } from './actions'; import { setCurrentVariableValue, variableStateCompleted, variableStateFetching } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { VariableRefresh } from '../types'; import { updateVariableOptions } from '../query/reducer'; import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { variablesInitTransaction } from './transactionReducer'; import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner'; import { setDataSourceSrv } from '@grafana/runtime'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; jest.mock('app/features/dashboard/services/TimeSrv', () => ({ getTimeSrv: jest.fn().mockReturnValue({ @@ -68,9 +68,11 @@ describe('processVariable', () => { // custom doesn't depend on any other variable // queryDependsOnCustom depends on custom // queryNoDepends doesn't depend on any other variable - const getAndSetupProcessVariableContext = () => { + const key = 'key'; + const getTestContext = () => { const custom = customBuilder() .withId('custom') + .withRootStateKey(key) .withName('custom') .withQuery('A,B,C') .withOptions('A', 'B', 'C') @@ -79,6 +81,7 @@ describe('processVariable', () => { const queryDependsOnCustom = queryBuilder() .withId('queryDependsOnCustom') + .withRootStateKey(key) .withName('queryDependsOnCustom') .withQuery('$custom.*') .withOptions('AA', 'AB', 'AC') @@ -87,6 +90,7 @@ describe('processVariable', () => { const queryNoDepends = queryBuilder() .withId('queryNoDepends') + .withRootStateKey(key) .withName('queryNoDepends') .withQuery('*') .withOptions('A', 'B', 'C') @@ -94,13 +98,15 @@ describe('processVariable', () => { .build(); const list = [custom, queryDependsOnCustom, queryNoDepends]; + const dashboard: any = { templating: { list } }; setVariableQueryRunner(new VariableQueryRunner()); return { + key, custom, queryDependsOnCustom, queryNoDepends, - list, + dashboard, }; }; @@ -108,33 +114,41 @@ describe('processVariable', () => { describe('when processVariable is dispatched for a custom variable without dependencies', () => { describe('and queryParams does not match variable', () => { it('then correct actions are dispatched', async () => { - const { list, custom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom } = getTestContext(); const queryParams: UrlQueryMap = {}; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams), true); - await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(custom))); + await tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, variableStateCompleted(toVariablePayload(custom))) + ); }); }); describe('and queryParams does match variable', () => { it('then correct actions are dispatched', async () => { - const { list, custom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom } = getTestContext(); const queryParams: UrlQueryMap = { 'var-custom': 'B' }; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: 'custom' }, { option: { text: 'B', value: 'B', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: 'custom' }, + { option: { text: 'B', value: 'B', selected: false } } + ) + ) ), - variableStateCompleted(toVariablePayload(custom)) + toKeyedAction(key, variableStateCompleted(toVariablePayload(custom))) ); }); }); @@ -148,15 +162,17 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); - await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(queryNoDepends))); + await tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, variableStateCompleted(toVariablePayload(queryNoDepends))) + ); }); }); @@ -165,36 +181,42 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { - results: [ - { value: 'A', text: 'A' }, - { value: 'B', text: 'B' }, - { value: 'C', text: 'C' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { + results: [ + { value: 'A', text: 'A' }, + { value: 'B', text: 'B' }, + { value: 'C', text: 'C' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'A', value: 'A', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'A', value: 'A', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))) ); }); }); @@ -205,22 +227,25 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'B', value: 'B', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'B', value: 'B', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))) ); }); }); @@ -230,40 +255,49 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { - results: [ - { value: 'A', text: 'A' }, - { value: 'B', text: 'B' }, - { value: 'C', text: 'C' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { + results: [ + { value: 'A', text: 'A' }, + { value: 'B', text: 'B' }, + { value: 'C', text: 'C' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'A', value: 'A', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'A', value: 'A', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'B', value: 'B', selected: false } } + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'B', value: 'B', selected: false } } + ) ) ) ); @@ -279,21 +313,21 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -303,41 +337,47 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { - results: [ - { value: 'AA', text: 'AA' }, - { value: 'AB', text: 'AB' }, - { value: 'AC', text: 'AC' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { + results: [ + { value: 'AA', text: 'AA' }, + { value: 'AB', text: 'AB' }, + { value: 'AC', text: 'AC' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AA', value: 'AA', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AA', value: 'AA', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -348,27 +388,30 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AB', value: 'AB', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AB', value: 'AB', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -378,45 +421,54 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { - results: [ - { value: 'AA', text: 'AA' }, - { value: 'AB', text: 'AB' }, - { value: 'AC', text: 'AC' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { + results: [ + { value: 'AA', text: 'AA' }, + { value: 'AB', text: 'AB' }, + { value: 'AC', text: 'AC' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AA', value: 'AA', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AA', value: 'AA', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AB', value: 'AB', selected: false } } + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AB', value: 'AB', selected: false } } + ) ) ) ); diff --git a/public/app/features/variables/state/reducers.test.ts b/public/app/features/variables/state/reducers.test.ts index faaf1f40c15..3e156e2236a 100644 --- a/public/app/features/variables/state/reducers.test.ts +++ b/public/app/features/variables/state/reducers.test.ts @@ -1,10 +1,12 @@ +import { createAction } from '@reduxjs/toolkit'; +import { VariableType } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { initialVariableModelState, QueryVariableModel } from '../types'; import { VariableAdapter, variableAdapters } from '../adapters'; -import { createAction } from '@reduxjs/toolkit'; import { cleanVariables, variablesReducer } from './variablesReducer'; -import { VariablesState, toVariablePayload, VariablePayload } from './types'; -import { VariableType } from '@grafana/data'; +import { VariablePayload, VariablesState } from './types'; +import { toVariablePayload } from '../utils'; const variableAdapter: VariableAdapter = { id: 'mock' as unknown as VariableType, diff --git a/public/app/features/variables/state/reducers.ts b/public/app/features/variables/state/reducers.ts index 005e0b9f33e..7cc63679118 100644 --- a/public/app/features/variables/state/reducers.ts +++ b/public/app/features/variables/state/reducers.ts @@ -1,20 +1,41 @@ -import { combineReducers } from '@reduxjs/toolkit'; -import { optionsPickerReducer } from '../pickers/OptionsPicker/reducer'; -import { variableEditorReducer } from '../editor/reducer'; +import { CombinedState, combineReducers, Reducer } from 'redux'; +import { initialOptionPickerState, optionsPickerReducer, OptionsPickerState } from '../pickers/OptionsPicker/reducer'; +import { initialVariableEditorState, variableEditorReducer, VariableEditorState } from '../editor/reducer'; import { variablesReducer } from './variablesReducer'; -import { transactionReducer } from './transactionReducer'; -import { variableInspectReducer } from '../inspect/reducer'; +import { initialTransactionState, transactionReducer, TransactionState } from './transactionReducer'; +import { initialVariableInspectState, variableInspectReducer, VariableInspectState } from '../inspect/reducer'; +import { initialVariablesState, VariablesState } from './types'; -export const templatingReducers = combineReducers({ - editor: variableEditorReducer, - variables: variablesReducer, - optionsPicker: optionsPickerReducer, - transaction: transactionReducer, - inspect: variableInspectReducer, -}); +export interface TemplatingState { + editor: VariableEditorState; + variables: VariablesState; + optionsPicker: OptionsPickerState; + transaction: TransactionState; + inspect: VariableInspectState; +} -export type TemplatingState = ReturnType; +let templatingReducers: Reducer>; -export default { - templating: templatingReducers, -}; +export function getTemplatingReducers() { + if (!templatingReducers) { + templatingReducers = combineReducers({ + editor: variableEditorReducer, + variables: variablesReducer, + optionsPicker: optionsPickerReducer, + transaction: transactionReducer, + inspect: variableInspectReducer, + }); + } + + return templatingReducers; +} + +export function getInitialTemplatingState() { + return { + editor: initialVariableEditorState, + variables: initialVariablesState, + optionsPicker: initialOptionPickerState, + transaction: initialTransactionState, + inspect: initialVariableInspectState, + }; +} diff --git a/public/app/features/variables/state/selectors.ts b/public/app/features/variables/state/selectors.ts index eb9de79d4a9..685b0b9b51d 100644 --- a/public/app/features/variables/state/selectors.ts +++ b/public/app/features/variables/state/selectors.ts @@ -2,52 +2,63 @@ import { StoreState } from '../../../types'; import { VariableModel } from '../types'; import { getState } from '../../../store/store'; import memoizeOne from 'memoize-one'; +import { getInitialTemplatingState, TemplatingState } from './reducers'; +import { toStateKey } from '../utils'; +import { KeyedVariableIdentifier, VariablesState } from './types'; export const getVariable = ( - id: string, + identifier: KeyedVariableIdentifier, state: StoreState = getState(), throwWhenMissing = true ): T => { - if (!state.templating.variables[id]) { + const { id, rootStateKey } = identifier; + const variablesState = getVariablesState(rootStateKey, state); + if (!variablesState.variables[id]) { if (throwWhenMissing) { throw new Error(`Couldn't find variable with id:${id}`); } return undefined as unknown as T; } - return state.templating.variables[id] as T; + return variablesState.variables[id] as T; }; -export const getFilteredVariables = (filter: (model: VariableModel) => boolean, state: StoreState = getState()) => { - return Object.values(state.templating.variables) +function getFilteredVariablesByKey( + filter: (model: VariableModel) => boolean, + key: string, + state: StoreState = getState() +) { + return Object.values(getVariablesState(key, state).variables) .filter(filter) .sort((s1, s2) => s1.index - s2.index); -}; +} -export const getVariableWithName = (name: string, state: StoreState = getState()) => { - return getVariable(name, state, false); -}; +export function getVariablesState(key: string, state: StoreState = getState()): TemplatingState { + return state.templating.keys[toStateKey(key)] ?? getInitialTemplatingState(); +} -export const getVariables = (state: StoreState = getState()): VariableModel[] => { - return getFilteredVariables(defaultVariablesFilter, state); -}; +export function getVariablesByKey(key: string, state: StoreState = getState()): VariableModel[] { + return getFilteredVariablesByKey(defaultVariablesFilter, key, state); +} -export function defaultVariablesFilter(variable: VariableModel): boolean { +function defaultVariablesFilter(variable: VariableModel): boolean { return variable.type !== 'system'; } -export const getSubMenuVariables = memoizeOne((variables: Record): VariableModel[] => { - return getVariables(getState()); -}); +export const getSubMenuVariables = memoizeOne( + (key: string, variables: Record): VariableModel[] => { + return getVariablesByKey(key, getState()); + } +); -export const getEditorVariables = (state: StoreState): VariableModel[] => { - return getVariables(state); +export const getEditorVariables = (key: string, state: StoreState): VariableModel[] => { + return getVariablesByKey(key, state); }; -export type GetVariables = typeof getVariables; +export type GetVariables = typeof getVariablesByKey; -export function getNewVariableIndex(state: StoreState = getState()): number { - return getNextVariableIndex(Object.values(state.templating.variables)); +export function getNewVariableIndex(key: string, state: StoreState = getState()): number { + return getNextVariableIndex(Object.values(getVariablesState(key, state).variables)); } export function getNextVariableIndex(variables: VariableModel[]): number { @@ -55,6 +66,47 @@ export function getNextVariableIndex(variables: VariableModel[]): number { return sorted.length > 0 ? sorted[sorted.length - 1].index + 1 : 0; } -export function getVariablesIsDirty(state: StoreState = getState()): boolean { - return state.templating.transaction.isDirty; +export function getVariablesIsDirty(key: string, state: StoreState = getState()): boolean { + return getVariablesState(key, state).transaction.isDirty; +} + +export function getIfExistsLastKey(state: StoreState = getState()): string | undefined { + return state.templating?.lastKey; +} + +export function getLastKey(state: StoreState = getState()): string { + if (!state.templating?.lastKey) { + throw new Error('Accessing lastKey without initializing it variables'); + } + + return state.templating.lastKey; +} + +// selectors used by template srv, assumes that lastKey is in state. Needs to change when/if dashboard redux state becomes keyed too. +export function getFilteredVariables(filter: (model: VariableModel) => boolean, state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return []; + } + return getFilteredVariablesByKey(filter, lastKey, state); +} + +export function getVariables(state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return []; + } + return getVariablesByKey(lastKey, state); +} + +export function getVariableWithName(name: string, state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return; + } + return getVariable({ id: name, rootStateKey: lastKey, type: 'query' }, state, false); +} + +export function getInstanceState(state: VariablesState, id: string) { + return state[id] as Model; } diff --git a/public/app/features/variables/state/setOptionFromUrl.test.ts b/public/app/features/variables/state/setOptionFromUrl.test.ts index 0c5cd3a66a5..5128deefe8a 100644 --- a/public/app/features/variables/state/setOptionFromUrl.test.ts +++ b/public/app/features/variables/state/setOptionFromUrl.test.ts @@ -2,12 +2,12 @@ import { variableAdapters } from '../adapters'; import { createCustomVariableAdapter } from '../custom/adapter'; import { customBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from './reducers'; -import { getTemplatingRootReducer } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { addVariable, setCurrentVariableValue } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { setOptionFromUrl } from './actions'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createCustomVariableAdapter()]); @@ -28,16 +28,31 @@ describe('when setOptionFromUrl is dispatched with a custom variable (no refresh ${null} | ${true} | ${['']} ${undefined} | ${true} | ${['']} `('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected, isMulti }) => { - const custom = customBuilder().withId('0').withMulti(isMulti).withOptions('A', 'B', 'C').withCurrent('A').build(); + const key = 'key'; + const custom = customBuilder() + .withId('0') + .withRootStateKey(key) + .withMulti(isMulti) + .withOptions('A', 'B', 'C') + .withCurrent('A') + .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: expected, value: expected, selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expected, value: expected, selected: false } } + ) + ) ) ); }); @@ -47,8 +62,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue contains same all value then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = allValue; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -56,16 +73,21 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } } + ) ) ) ); @@ -74,8 +96,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue differs from all value then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = 'X'; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -83,14 +107,19 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'X', value: 'X', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'X', value: 'X', selected: false } }) + ) ) ); }); @@ -98,8 +127,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue differs but matches an option then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = 'B'; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -107,14 +138,19 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'B', value: 'B', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'B', value: 'B', selected: false } }) + ) ) ); }); @@ -122,8 +158,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and custom all value matches an option', async () => { const allValue = '.*'; const urlValue = allValue; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -133,16 +171,21 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v custom.options[2].value = 'special value for .*'; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: '.*', value: 'special value for .*', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: '.*', value: 'special value for .*', selected: false } } + ) ) ) ); diff --git a/public/app/features/variables/state/sharedReducer.test.ts b/public/app/features/variables/state/sharedReducer.test.ts index 7721a92c583..ec1b2deb01b 100644 --- a/public/app/features/variables/state/sharedReducer.test.ts +++ b/public/app/features/variables/state/sharedReducer.test.ts @@ -17,7 +17,7 @@ import { variableStateNotStarted, } from './sharedReducer'; import { ConstantVariableModel, QueryVariableModel, VariableHide, VariableOption } from '../types'; -import { initialVariablesState, toVariablePayload, VariableIdentifier, VariablesState } from './types'; +import { initialVariablesState, KeyedVariableIdentifier, VariablesState } from './types'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { initialQueryVariableModelState } from '../query/reducer'; @@ -26,6 +26,7 @@ import { changeVariableNameSucceeded } from '../editor/reducer'; import { createConstantVariableAdapter } from '../constant/adapter'; import { initialConstantVariableModelState } from '../constant/reducer'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createQueryVariableAdapter(), createConstantVariableAdapter()]); @@ -102,6 +103,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -115,6 +117,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -140,6 +143,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -153,6 +157,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -179,6 +184,7 @@ describe('sharedReducer', () => { ...initialState, '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -192,6 +198,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -205,6 +212,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -219,6 +227,7 @@ describe('sharedReducer', () => { '11': { ...initialQueryVariableModelState, id: '11', + rootStateKey: 'key', name: 'copy_of_Name-1', index: 3, label: 'Label-1', @@ -237,6 +246,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -250,6 +260,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -263,6 +274,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -286,6 +298,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -299,6 +312,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -312,6 +326,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -548,7 +563,7 @@ describe('sharedReducer', () => { const constantAdapter = createConstantVariableAdapter(); const { initialState: constantAdapterState } = getVariableTestContext(constantAdapter); const newType = 'constant' as VariableType; - const identifier: VariableIdentifier = { id: '0', type: 'query' }; + const identifier: KeyedVariableIdentifier = { id: '0', type: 'query', rootStateKey: 'key' }; const payload = toVariablePayload(identifier, { newType }); reducerTester() .givenReducer(sharedReducer, cloneDeep(queryAdapterState)) @@ -564,6 +579,7 @@ describe('sharedReducer', () => { ...constantAdapterState, '0': { ...constantAdapterState[0], + rootStateKey: 'key', name: 'test', description: 'new description', label: 'new label', diff --git a/public/app/features/variables/state/sharedReducer.ts b/public/app/features/variables/state/sharedReducer.ts index 1a4225b14ad..7b5e4ecb6ce 100644 --- a/public/app/features/variables/state/sharedReducer.ts +++ b/public/app/features/variables/state/sharedReducer.ts @@ -2,11 +2,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { cloneDeep, defaults as lodashDefaults } from 'lodash'; import { LoadingState, VariableType } from '@grafana/data'; import { VariableModel, VariableOption, VariableWithOptions } from '../types'; -import { AddVariable, getInstanceState, initialVariablesState, VariablePayload, VariablesState } from './types'; +import { AddVariable, initialVariablesState, VariablePayload, VariablesState } from './types'; import { variableAdapters } from '../adapters'; import { changeVariableNameSucceeded } from '../editor/reducer'; import { ensureStringValues } from '../utils'; -import { getNextVariableIndex } from './selectors'; +import { getInstanceState, getNextVariableIndex } from './selectors'; const sharedReducerSlice = createSlice({ name: 'templating/shared', @@ -100,11 +100,12 @@ const sharedReducerSlice = createSlice({ }, changeVariableType: (state: VariablesState, action: PayloadAction>) => { const { id } = action.payload; - const { label, name, index, description } = state[id]; + const { label, name, index, description, rootStateKey } = state[id]; state[id] = { ...cloneDeep(variableAdapters.get(action.payload.data.newType).initialState), - id: id, + id, + rootStateKey: rootStateKey, label, name, index, diff --git a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts index c821aaafc63..7163f2c9bf0 100644 --- a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts +++ b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts @@ -2,12 +2,12 @@ import { variableAdapters } from '../adapters'; import { constantBuilder, customBuilder } from '../shared/testing/builders'; import { DashboardState, StoreState } from '../../../types'; import { initialState } from '../../dashboard/state/reducers'; -import { TemplatingState } from './reducers'; import { ExtendedUrlQueryMap } from '../utils'; import { templateVarsChangedInUrl } from './actions'; import { createCustomVariableAdapter } from '../custom/adapter'; import { VariablesState } from './types'; import { DashboardModel } from '../../dashboard/state'; +import { getPreloadedState } from './helpers'; import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableModel } from '../types'; @@ -18,9 +18,11 @@ variableAdapters.setInit(() => [createCustomVariableAdapter(), createConstantVar async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}, variable: VariableModel | undefined = undefined) { jest.clearAllMocks(); + const key = 'key'; if (!variable) { variable = customBuilder() .withId('variable') + .withRootStateKey(key) .withName('variable') .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') @@ -43,15 +45,14 @@ async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}, variable: V }; const variables: VariablesState = { variable }; - const templating = { variables } as unknown as TemplatingState; const state: Partial = { dashboard, - templating, + ...getPreloadedState(key, { variables }), }; const getState = () => state as unknown as StoreState; const dispatch = jest.fn(); - const thunk = templateVarsChangedInUrl(urlQueryMap); + const thunk = templateVarsChangedInUrl(key, urlQueryMap); await thunk(dispatch, getState, undefined); @@ -125,6 +126,7 @@ describe('templateVarsChangedInUrl', () => { it('then the value should change to the value in dashboard json and dashboard should be refreshed', async () => { const constant = constantBuilder() .withId('variable') + .withRootStateKey('key') .withName('variable') .withQuery('default value in dash.json') .build(); diff --git a/public/app/features/variables/state/transactionReducer.ts b/public/app/features/variables/state/transactionReducer.ts index 974943a61b2..05d4a1c6cdf 100644 --- a/public/app/features/variables/state/transactionReducer.ts +++ b/public/app/features/variables/state/transactionReducer.ts @@ -52,14 +52,14 @@ const transactionSlice = createSlice({ }); function actionAffectsDirtyState(action: AnyAction): boolean { - return [ - removeVariable.type, - addVariable.type, - changeVariableProp.type, - changeVariableOrder.type, - duplicateVariable.type, - changeVariableType.type, - ].includes(action.type); + return ( + removeVariable.match(action) || + addVariable.match(action) || + changeVariableProp.match(action) || + changeVariableOrder.match(action) || + duplicateVariable.match(action) || + changeVariableType.match(action) + ); } export const { variablesInitTransaction, variablesClearTransaction, variablesCompleteTransaction } = diff --git a/public/app/features/variables/state/types.ts b/public/app/features/variables/state/types.ts index 0e652946b08..603f18753e0 100644 --- a/public/app/features/variables/state/types.ts +++ b/public/app/features/variables/state/types.ts @@ -6,15 +6,17 @@ export interface VariablesState extends Record {} export const initialVariablesState: VariablesState = {}; -export const getInstanceState = (state: VariablesState, id: string) => { - return state[id] as Model; -}; - export interface VariableIdentifier { type: VariableType; id: string; } +export interface KeyedVariableIdentifier { + type: VariableType; + id: string; + rootStateKey: string; +} + export interface VariablePayload extends VariableIdentifier { data: T; } @@ -24,21 +26,3 @@ export interface AddVariable { index: number; // the order in variables list model: T; } - -export const toVariableIdentifier = (variable: VariableModel): VariableIdentifier => { - return { type: variable.type, id: variable.id }; -}; - -export function toVariablePayload( - identifier: VariableIdentifier, - data?: T -): VariablePayload; -// eslint-disable-next-line -export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; -// eslint-disable-next-line -export function toVariablePayload( - obj: VariableIdentifier | VariableModel, - data?: T -): VariablePayload { - return { type: obj.type, id: obj.id, data: data as T }; -} diff --git a/public/app/features/variables/state/upgradeLegacyQueries.test.ts b/public/app/features/variables/state/upgradeLegacyQueries.test.ts index 27a66721e74..d3f68d6e90d 100644 --- a/public/app/features/variables/state/upgradeLegacyQueries.test.ts +++ b/public/app/features/variables/state/upgradeLegacyQueries.test.ts @@ -1,10 +1,12 @@ import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { VariableSupportType } from '@grafana/data'; -import { toVariableIdentifier } from './types'; import { upgradeLegacyQueries } from './actions'; import { changeVariableProp } from './sharedReducer'; import { thunkTester } from '../../../../test/core/thunk/thunkTester'; import { TransactionStatus, VariableModel } from '../types'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { getPreloadedState } from './helpers'; +import { toKeyedVariableIdentifier } from '../utils'; interface Args { query?: any; @@ -18,22 +20,23 @@ function getTestContext({ datasource, transactionStatus = TransactionStatus.Fetching, }: Args = {}) { + const key = 'key'; variable = variable ?? queryBuilder() .withId('query') + .withRootStateKey(key) .withName('query') .withQuery(query) .withDatasource({ uid: 'test-data', type: 'test-data' }) .build(); - const state = { - templating: { - transaction: { status: transactionStatus }, - variables: { - [variable.id]: variable, - }, + const templatingState = { + transaction: { status: transactionStatus, uid: key, isDirty: false }, + variables: { + [variable.id]: variable, }, }; + const state = getPreloadedState(key, templatingState); datasource = datasource ?? { name: 'TestData', metricFindQuery: () => undefined, @@ -41,32 +44,35 @@ function getTestContext({ }; const get = jest.fn().mockResolvedValue(datasource); const getDatasourceSrv = jest.fn().mockReturnValue({ get }); - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); - return { state, get, getDatasourceSrv, identifier }; + return { key, state, get, getDatasourceSrv, identifier }; } describe('upgradeLegacyQueries', () => { describe('when called with a query variable for a standard variable supported data source that has not been upgraded', () => { it('then it should dispatch changeVariableProp', async () => { - const { state, identifier, get, getDatasourceSrv } = getTestContext({ query: '*' }); + const { key, state, identifier, get, getDatasourceSrv } = getTestContext({ query: '*' }); const dispatchedActions = await thunkTester(state) .givenThunk(upgradeLegacyQueries) .whenThunkIsDispatched(identifier, getDatasourceSrv); expect(dispatchedActions).toEqual([ - changeVariableProp({ - type: 'query', - id: 'query', - data: { - propName: 'query', - propValue: { - refId: 'TestData-query-Variable-Query', - query: '*', + toKeyedAction( + key, + changeVariableProp({ + type: 'query', + id: 'query', + data: { + propName: 'query', + propValue: { + refId: 'TestData-query-Variable-Query', + query: '*', + }, }, - }, - }), + }) + ), ]); expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledWith({ uid: 'test-data', type: 'test-data' }); @@ -161,7 +167,7 @@ describe('upgradeLegacyQueries', () => { describe('when called with a custom variable', () => { it('then it should not dispatch any actions', async () => { - const variable = customBuilder().withId('custom').withName('custom').build(); + const variable = customBuilder().withId('custom').withRootStateKey('key').withName('custom').build(); const { state, identifier, get, getDatasourceSrv } = getTestContext({ variable }); const dispatchedActions = await thunkTester(state) diff --git a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx index cc1dbaf9743..7ad4152683d 100644 --- a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx +++ b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx @@ -1,12 +1,13 @@ import React, { ChangeEvent, FocusEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useState } from 'react'; import { TextBoxVariableModel } from '../types'; -import { toVariablePayload } from '../state/types'; import { changeVariableProp } from '../state/sharedReducer'; import { VariablePickerProps } from '../pickers/types'; import { Input } from '@grafana/ui'; import { variableAdapters } from '../adapters'; import { useDispatch } from 'react-redux'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export interface Props extends VariablePickerProps {} @@ -18,13 +19,21 @@ export function TextBoxVariablePicker({ variable, onVariableChange }: Props): Re }, [variable]); const updateVariable = useCallback(() => { + if (!variable.rootStateKey) { + console.error('Cannot update variable without rootStateKey'); + return; + } + if (variable.current.value === updatedValue) { return; } dispatch( - changeVariableProp( - toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) + toKeyedAction( + variable.rootStateKey, + changeVariableProp( + toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) + ) ) ); diff --git a/public/app/features/variables/textbox/actions.test.ts b/public/app/features/variables/textbox/actions.test.ts index e605a69e80b..4c38b863523 100644 --- a/public/app/features/variables/textbox/actions.test.ts +++ b/public/app/features/variables/textbox/actions.test.ts @@ -4,11 +4,12 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { createTextBoxOptions } from './reducer'; import { addVariable, changeVariableProp, setCurrentVariableValue } from '../state/sharedReducer'; import { textboxBuilder } from '../shared/testing/builders'; import { locationService } from '@grafana/runtime'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); @@ -32,16 +33,25 @@ describe('textbox actions', () => { selected: false, }; - const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); + const key = 'key'; + const variable = textboxBuilder() + .withId('textbox') + .withRootStateKey(key) + .withName('textbox') + .withCurrent('A') + .withQuery('A') + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createTextBoxOptions(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction(key, createTextBoxOptions(toVariablePayload(variable))), + toKeyedAction(key, setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-textbox': 'A' }); }); @@ -50,16 +60,31 @@ describe('textbox actions', () => { describe('when setTextBoxVariableOptionsFromUrl is dispatched', () => { it('then correct actions are dispatched', async () => { const urlValue = 'bB'; - const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); + const key = 'key'; + const variable = textboxBuilder() + .withId('textbox') + .withRootStateKey(key) + .withName('textbox') + .withCurrent('A') + .withQuery('A') + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(setTextBoxVariableOptionsFromUrl(toVariablePayload(variable), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched( + setTextBoxVariableOptionsFromUrl(toKeyedVariableIdentifier(variable), urlValue), + true + ); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: 'bB' })), - setCurrentVariableValue(toVariablePayload(variable, { option: { text: 'bB', value: 'bB', selected: false } })) + toKeyedAction(key, changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: 'bB' }))), + toKeyedAction( + key, + setCurrentVariableValue(toVariablePayload(variable, { option: { text: 'bB', value: 'bB', selected: false } })) + ) ); }); }); diff --git a/public/app/features/variables/textbox/actions.ts b/public/app/features/variables/textbox/actions.ts index 662f45b4791..4e10c499168 100644 --- a/public/app/features/variables/textbox/actions.ts +++ b/public/app/features/variables/textbox/actions.ts @@ -3,28 +3,36 @@ import { ThunkResult } from '../../../types'; import { getVariable } from '../state/selectors'; import { variableAdapters } from '../adapters'; import { createTextBoxOptions } from './reducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { setOptionFromUrl } from '../state/actions'; import { UrlQueryValue } from '@grafana/data'; import { changeVariableProp } from '../state/sharedReducer'; -import { ensureStringValues } from '../utils'; +import { ensureStringValues, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; -export const updateTextBoxVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateTextBoxVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch, getState) => { - await dispatch(createTextBoxOptions(toVariablePayload(identifier))); + const { rootStateKey, type } = identifier; + dispatch(toKeyedAction(rootStateKey, createTextBoxOptions(toVariablePayload(identifier)))); - const variableInState = getVariable(identifier.id, getState()); - await variableAdapters.get(identifier.type).setValue(variableInState, variableInState.options[0], true); + const variableInState = getVariable(identifier, getState()); + await variableAdapters.get(type).setValue(variableInState, variableInState.options[0], true); }; }; export const setTextBoxVariableOptionsFromUrl = - (identifier: VariableIdentifier, urlValue: UrlQueryValue): ThunkResult => + (identifier: KeyedVariableIdentifier, urlValue: UrlQueryValue): ThunkResult => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, getState()); const stringUrlValue = ensureStringValues(urlValue); - dispatch(changeVariableProp(toVariablePayload(variableInState, { propName: 'query', propValue: stringUrlValue }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(variableInState, { propName: 'query', propValue: stringUrlValue })) + ) + ); - await dispatch(setOptionFromUrl(toVariableIdentifier(variableInState), stringUrlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variableInState), stringUrlValue)); }; diff --git a/public/app/features/variables/textbox/adapter.test.ts b/public/app/features/variables/textbox/adapter.test.ts index e2e9d1db7ba..f28b7fd5c7c 100644 --- a/public/app/features/variables/textbox/adapter.test.ts +++ b/public/app/features/variables/textbox/adapter.test.ts @@ -11,6 +11,7 @@ describe('createTextBoxVariableAdapter', () => { it('then the model should be correct', () => { const text = textboxBuilder() .withId('text') + .withRootStateKey('key') .withName('text') .withQuery('query') .withOriginalQuery('original') @@ -41,6 +42,7 @@ describe('createTextBoxVariableAdapter', () => { it('then the model should be correct', () => { const text = textboxBuilder() .withId('text') + .withRootStateKey('key') .withName('text') .withQuery('query') .withOriginalQuery('original') diff --git a/public/app/features/variables/textbox/adapter.ts b/public/app/features/variables/textbox/adapter.ts index c910867f020..a916520eaca 100644 --- a/public/app/features/variables/textbox/adapter.ts +++ b/public/app/features/variables/textbox/adapter.ts @@ -8,7 +8,7 @@ import { VariableAdapter } from '../adapters'; import { TextBoxVariablePicker } from './TextBoxVariablePicker'; import { TextBoxVariableEditor } from './TextBoxVariableEditor'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; +import { toKeyedVariableIdentifier } from '../utils'; export const createTextBoxVariableAdapter = (): VariableAdapter => { return { @@ -23,16 +23,16 @@ export const createTextBoxVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setTextBoxVariableOptionsFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setTextBoxVariableOptionsFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateTextBoxVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable, saveCurrentAsDefault) => { - const { index, id, state, global, originalQuery, ...rest } = cloneDeep(variable); + const { index, id, state, global, originalQuery, rootStateKey, ...rest } = cloneDeep(variable); if (variable.query !== originalQuery && !saveCurrentAsDefault) { const origQuery = originalQuery ?? ''; diff --git a/public/app/features/variables/textbox/reducer.test.ts b/public/app/features/variables/textbox/reducer.test.ts index fc4c4663856..fbd83a44318 100644 --- a/public/app/features/variables/textbox/reducer.test.ts +++ b/public/app/features/variables/textbox/reducer.test.ts @@ -1,10 +1,11 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createTextBoxOptions, textBoxVariableReducer } from './reducer'; import { TextBoxVariableModel } from '../types'; import { createTextBoxVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('textBoxVariableReducer', () => { const adapter = createTextBoxVariableAdapter(); diff --git a/public/app/features/variables/textbox/reducer.ts b/public/app/features/variables/textbox/reducer.ts index d8850d80225..405cde2da91 100644 --- a/public/app/features/variables/textbox/reducer.ts +++ b/public/app/features/variables/textbox/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialVariableModelState, TextBoxVariableModel, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export const initialTextBoxVariableModelState: TextBoxVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/types.ts b/public/app/features/variables/types.ts index 0f8b6835dc9..5d894ab35b8 100644 --- a/public/app/features/variables/types.ts +++ b/public/app/features/variables/types.ts @@ -131,6 +131,7 @@ export interface SystemVariable string }> exten export interface VariableModel extends BaseVariableModel { id: string; + rootStateKey: string | null; global: boolean; hide: VariableHide; skipUrlSync: boolean; @@ -142,6 +143,7 @@ export interface VariableModel extends BaseVariableModel { export const initialVariableModelState: VariableModel = { id: NEW_VARIABLE_ID, + rootStateKey: null, name: '', label: null, type: '' as unknown as VariableType, diff --git a/public/app/features/variables/utils.ts b/public/app/features/variables/utils.ts index 96068a50272..d6964a6aa67 100644 --- a/public/app/features/variables/utils.ts +++ b/public/app/features/variables/utils.ts @@ -9,6 +9,8 @@ import { variableAdapters } from './adapters'; import { safeStringifyValue } from 'app/core/utils/explore'; import { StoreState } from '../../types'; import { getState } from '../../store/store'; +import { getVariablesState } from './state/selectors'; +import { KeyedVariableIdentifier, VariableIdentifier, VariablePayload } from './state/types'; /* * This regex matches 3 types of variable reference with an optional format specifier @@ -256,6 +258,32 @@ export function ensureStringValues(value: any | any[]): string | string[] { return ''; } -export function hasOngoingTransaction(state: StoreState = getState()): boolean { - return state.templating.transaction.status !== TransactionStatus.NotStarted; +export function hasOngoingTransaction(key: string, state: StoreState = getState()): boolean { + return getVariablesState(key, state).transaction.status !== TransactionStatus.NotStarted; +} + +export function toStateKey(key: string | null | undefined): string { + return String(key); +} + +export const toKeyedVariableIdentifier = (variable: VariableModel): KeyedVariableIdentifier => { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + + return { type: variable.type, id: variable.id, rootStateKey: variable.rootStateKey }; +}; + +export function toVariablePayload( + identifier: VariableIdentifier, + data?: T +): VariablePayload; +// eslint-disable-next-line +export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; +// eslint-disable-next-line +export function toVariablePayload( + obj: VariableIdentifier | VariableModel, + data?: T +): VariablePayload { + return { type: obj.type, id: obj.id, data: data as T }; } diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ea3b649bb92..7c4f195e836 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -272,6 +272,12 @@ export interface ExternalAlertmanagersResponse { data: ExternalAlertmanagers; status: 'string'; } + +export interface ExternalAlertmanagerConfig { + alertmanagers: string[]; + alertmanagersChoice: string; +} + export enum AlertManagerImplementation { cortex = 'cortex', prometheus = 'prometheus', diff --git a/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx index aa960f9769b..b8a42a2837d 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx @@ -72,6 +72,7 @@ export function MathExpressionQueryField({ onBlur={(value) => { if (value !== query) { onChange(value); + onRunQuery(); } }} onBeforeEditorMount={(monaco: Monaco) => diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index a4c92c98146..91881025ddc 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -50,7 +50,6 @@ import { StartQueryRequest, TSDBResponse, Dimensions, - MetricFindSuggestData, CloudWatchLogsRequest, } from './types'; import { CloudWatchLanguageProvider } from './language_provider'; @@ -557,40 +556,8 @@ export class CloudWatchDatasource ); } - transformSuggestDataFromDataframes(suggestData: TSDBResponse): MetricFindSuggestData[] { - const frames = toDataQueryResponse({ data: suggestData }).data as DataFrame[]; - const table = toLegacyResponseData(frames[0]) as TableData; - - return table.rows.map(([text, value]) => ({ - text, - value, - label: value, - })); - } - - doMetricQueryRequest(subtype: string, parameters: any): Promise { - const range = this.timeSrv.timeRange(); - return lastValueFrom( - this.awsRequest(DS_QUERY_ENDPOINT, { - from: range.from.valueOf().toString(), - to: range.to.valueOf().toString(), - queries: [ - { - refId: 'metricFindQuery', - intervalMs: 1, // dummy - maxDataPoints: 1, // dummy - datasource: this.getRef(), - type: 'metricFindQuery', - subtype: subtype, - ...parameters, - }, - ], - }).pipe( - map((r) => { - return this.transformSuggestDataFromDataframes(r); - }) - ) - ); + doMetricResourceRequest(subtype: string, parameters?: any): Promise> { + return this.getResource(subtype, parameters); } makeLogActionRequest( @@ -677,14 +644,14 @@ export class CloudWatchDatasource } getRegions(): Promise> { - return this.doMetricQueryRequest('regions', null).then((regions: any) => [ + return this.doMetricResourceRequest('regions').then((regions: any) => [ { label: 'default', value: 'default', text: 'default' }, ...regions, ]); } getNamespaces() { - return this.doMetricQueryRequest('namespaces', null); + return this.doMetricResourceRequest('namespaces'); } async getMetrics(namespace: string | undefined, region?: string) { @@ -692,14 +659,14 @@ export class CloudWatchDatasource return []; } - return this.doMetricQueryRequest('metrics', { + return this.doMetricResourceRequest('metrics', { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), }); } async getAllMetrics(region: string): Promise> { - const values = await this.doMetricQueryRequest('all_metrics', { + const values = await this.doMetricResourceRequest('all-metrics', { region: this.templateSrv.replace(this.getActualRegion(region)), }); @@ -716,10 +683,10 @@ export class CloudWatchDatasource return []; } - return this.doMetricQueryRequest('dimension_keys', { + return this.doMetricResourceRequest('dimension-keys', { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), - dimensionFilters: this.convertDimensionFormat(dimensionFilters, {}), + dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})), metricName, }); } @@ -735,37 +702,37 @@ export class CloudWatchDatasource return []; } - const values = await this.doMetricQueryRequest('dimension_values', { + const values = await this.doMetricResourceRequest('dimension-values', { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), metricName: this.templateSrv.replace(metricName.trim()), dimensionKey: this.templateSrv.replace(dimensionKey), - dimensions: this.convertDimensionFormat(filterDimensions, {}), + dimensions: JSON.stringify(this.convertDimensionFormat(filterDimensions, {})), }); return values; } getEbsVolumeIds(region: string, instanceId: string) { - return this.doMetricQueryRequest('ebs_volume_ids', { + return this.doMetricResourceRequest('ebs-volume-ids', { region: this.templateSrv.replace(this.getActualRegion(region)), instanceId: this.templateSrv.replace(instanceId), }); } getEc2InstanceAttribute(region: string, attributeName: string, filters: any) { - return this.doMetricQueryRequest('ec2_instance_attribute', { + return this.doMetricResourceRequest('ec2-instance-attribute', { region: this.templateSrv.replace(this.getActualRegion(region)), attributeName: this.templateSrv.replace(attributeName), - filters: filters, + filters: JSON.stringify(filters), }); } getResourceARNs(region: string, resourceType: string, tags: any) { - return this.doMetricQueryRequest('resource_arns', { + return this.doMetricResourceRequest('resource-arns', { region: this.templateSrv.replace(this.getActualRegion(region)), resourceType: this.templateSrv.replace(resourceType), - tags: tags, + tags: JSON.stringify(tags), }); } diff --git a/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts b/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts index 1f54866cb64..9495b07c24d 100644 --- a/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts +++ b/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts @@ -5,7 +5,6 @@ import { getTemplateSrv } from '@grafana/runtime'; import { CloudWatchDatasource } from '../../datasource'; import cloudWatchMetricMathLanguageDefinition from '../definition'; import { Monaco, monacoTypes } from '@grafana/ui'; -import { IPosition } from 'monaco-editor'; import { METRIC_MATH_FNS, METRIC_MATH_KEYWORDS, @@ -15,7 +14,7 @@ import { } from '../language'; import * as MetricMathTestData from '../../__mocks__/metric-math-test-data'; -const getSuggestions = async (value: string, position: IPosition) => { +const getSuggestions = async (value: string, position: monacoTypes.IPosition) => { const setup = new MetricMathCompletionItemProvider( { getVariables: () => [], diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index 70e1262cbe6..2db9cbf4f52 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -11,12 +11,12 @@ import * as redux from 'app/store/store'; import { CloudWatchDatasource } from '../datasource'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { - MetricEditorMode, CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchLogsQueryStatus, CloudWatchMetricsQuery, LogAction, + MetricEditorMode, MetricQueryType, } from '../types'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ @@ -506,16 +506,15 @@ describe('CloudWatchDatasource', () => { }); }); }); - describe('when regions query is used', () => { describe('and region param is left out', () => { it('should use the default region', async () => { const { ds, instanceSettings } = getTestContext(); - ds.doMetricQueryRequest = jest.fn().mockResolvedValue([]); + ds.doMetricResourceRequest = jest.fn().mockResolvedValue([]); await ds.metricFindQuery('metrics(testNamespace)'); - expect(ds.doMetricQueryRequest).toHaveBeenCalledWith('metrics', { + expect(ds.doMetricResourceRequest).toHaveBeenCalledWith('metrics', { namespace: 'testNamespace', region: instanceSettings.jsonData.defaultRegion, }); @@ -525,11 +524,11 @@ describe('CloudWatchDatasource', () => { describe('and region param is defined by user', () => { it('should use the user defined region', async () => { const { ds } = getTestContext(); - ds.doMetricQueryRequest = jest.fn().mockResolvedValue([]); + ds.doMetricResourceRequest = jest.fn().mockResolvedValue([]); await ds.metricFindQuery('metrics(testNamespace2, custom-region)'); - expect(ds.doMetricQueryRequest).toHaveBeenCalledWith('metrics', { + expect(ds.doMetricResourceRequest).toHaveBeenCalledWith('metrics', { namespace: 'testNamespace2', region: 'custom-region', }); @@ -706,9 +705,11 @@ describe('CloudWatchDatasource', () => { describe('When performing CloudWatch query with template variables', () => { let templateSrv: TemplateSrv; beforeEach(() => { + const key = 'key'; const var1: CustomVariableModel = { ...initialVariableModelState, id: 'var1', + rootStateKey: key, name: 'var1', index: 0, current: { value: 'var1-foo', text: 'var1-foo', selected: true }, @@ -722,6 +723,7 @@ describe('CloudWatchDatasource', () => { const var2: CustomVariableModel = { ...initialVariableModelState, id: 'var2', + rootStateKey: key, name: 'var2', index: 1, current: { value: 'var2-foo', text: 'var2-foo', selected: true }, @@ -735,6 +737,7 @@ describe('CloudWatchDatasource', () => { const var3: CustomVariableModel = { ...initialVariableModelState, id: 'var3', + rootStateKey: key, name: 'var3', index: 2, current: { value: ['var3-foo', 'var3-baz'], text: 'var3-foo + var3-baz', selected: true }, @@ -752,6 +755,7 @@ describe('CloudWatchDatasource', () => { const var4: CustomVariableModel = { ...initialVariableModelState, id: 'var4', + rootStateKey: key, name: 'var4', index: 3, options: [ @@ -767,7 +771,7 @@ describe('CloudWatchDatasource', () => { type: 'custom', }; const variables = [var1, var2, var3, var4]; - const state = convertToStoreState(variables); + const state = convertToStoreState(key, variables); templateSrv = new TemplateSrv(getTemplateSrvDependencies(state)); templateSrv.init(variables); }); diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index 365b5e7eac4..2401c0c3721 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -367,9 +367,3 @@ export interface MetricQuery { maxDataPoints?: number; intervalMs?: number; } - -export interface MetricFindSuggestData { - text: string; - label: string; - value: string; -} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts index f8885924173..7ae8bca81c0 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/__mocks__/query_ctrl.ts @@ -1,5 +1,3 @@ -import { auto } from 'angular'; - export class QueryCtrl { target: any; datasource: any; @@ -8,7 +6,7 @@ export class QueryCtrl { hasRawMode = false; error = ''; - constructor(public $scope: any, _$injector: auto.IInjectorService) { + constructor(public $scope: any) { this.panelCtrl = this.panelCtrl || { panel: {} }; this.target = this.target || { target: '' }; this.panel = this.panelCtrl.panel; diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index 57c45634b27..992fcf4f0e9 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -11,7 +11,6 @@ import { import { GrafanaDatasource } from '../datasource'; import { defaultQuery, GrafanaQuery, GrafanaQueryType } from '../types'; import { config, getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; -import { contextSrv } from 'app/core/services/context_srv'; type Props = QueryEditorProps; @@ -47,7 +46,7 @@ export class QueryEditor extends PureComponent { constructor(props: Props) { super(props); - if (config.featureToggles.panelTitleSearch && contextSrv.isGrafanaAdmin) { + if (config.featureToggles.panelTitleSearch) { this.queryTypes.push({ label: 'Search', value: GrafanaQueryType.Search, diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index dba531a1e88..e9cff1ca03a 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -445,10 +445,11 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if (this.isFlux) { + if (this.isFlux || (config.featureToggles.influxdbBackendMigration && this.access === 'proxy')) { const target: InfluxQuery = { refId: 'metricFindQuery', query, + rawQuery: true, }; return lastValueFrom( super.query({ diff --git a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap index e41e5c921ef..5562e353c36 100644 --- a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap @@ -67,38 +67,168 @@ exports[`LokiExploreQueryEditor should render component 1`] = ` "labelFetchTs": 0, "labelKeys": Array [], "labelsCache": LRUCache { - Symbol(max): 10, - Symbol(lengthCalculator): [Function], - Symbol(allowStale): false, - Symbol(maxAge): 0, - Symbol(dispose): undefined, - Symbol(noDisposeOnSet): false, - Symbol(updateAgeOnGet): false, - Symbol(cache): Map {}, - Symbol(lruList): Yallist { - "head": null, + "allowStale": false, + "disposeAfter": null, + "disposed": null, + "free": Stack { + "heap": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], "length": 0, - "tail": null, }, - Symbol(length): 0, + "head": 0, + "initialFill": 1, + "keyList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + "keyMap": Map {}, + "max": 10, + "maxSize": 0, + "next": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "noDisposeOnSet": false, + "prev": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "size": 0, + "sizeCalculation": undefined, + "tail": 0, + "ttl": 0, + "ttlAutopurge": false, + "ttlResolution": 1, + "updateAgeOnGet": false, + "valList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], }, "lookupsDisabled": false, "request": [Function], "seriesCache": LRUCache { - Symbol(max): 10, - Symbol(lengthCalculator): [Function], - Symbol(allowStale): false, - Symbol(maxAge): 0, - Symbol(dispose): undefined, - Symbol(noDisposeOnSet): false, - Symbol(updateAgeOnGet): false, - Symbol(cache): Map {}, - Symbol(lruList): Yallist { - "head": null, + "allowStale": false, + "disposeAfter": null, + "disposed": null, + "free": Stack { + "heap": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], "length": 0, - "tail": null, }, - Symbol(length): 0, + "head": 0, + "initialFill": 1, + "keyList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + "keyMap": Map {}, + "max": 10, + "maxSize": 0, + "next": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "noDisposeOnSet": false, + "prev": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "size": 0, + "sizeCalculation": undefined, + "tail": 0, + "ttl": 0, + "ttlAutopurge": false, + "ttlResolution": 1, + "updateAgeOnGet": false, + "valList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], }, "start": [Function], "started": false, diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 80bf63afd0d..ef4691fdd24 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -78,8 +78,8 @@ export default class LokiLanguageProvider extends LanguageProvider { * not account for different size of a response. If that is needed a `length` function can be added in the options. * 10 as a max size is totally arbitrary right now. */ - private seriesCache = new LRU>(10); - private labelsCache = new LRU(10); + private seriesCache = new LRU>({ max: 10 }); + private labelsCache = new LRU({ max: 10 }); constructor(datasource: LokiDatasource, initialValues?: any) { super(); diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 1a4c1395024..2397cb8c7ed 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -72,7 +72,7 @@ export class PrometheusDatasource access: 'direct' | 'proxy'; basicAuth: any; withCredentials: any; - metricsNameCache = new LRU(10); + metricsNameCache = new LRU({ max: 10 }); interval: string; queryTimeout: string | undefined; httpMethod: string; diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 1e1a63df6b5..6d439b38e26 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -90,7 +90,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { * not account for different size of a response. If that is needed a `length` function can be added in the options. * 10 as a max size is totally arbitrary right now. */ - private labelsCache = new LRU>(10); + private labelsCache = new LRU>({ max: 10 }); constructor(datasource: PrometheusDatasource, initialValues?: Partial) { super(); diff --git a/public/app/plugins/datasource/prometheus/query_hints.test.ts b/public/app/plugins/datasource/prometheus/query_hints.test.ts index 381934e694d..66b97fa7d33 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.test.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.test.ts @@ -88,6 +88,21 @@ describe('getQueryHints()', () => { expect(hints).toEqual([]); }); + it('returns a rate hint with action for a counter metric with labels', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_total{job="grafana"}', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('Selected metric looks like a counter'); + expect(hints![0].fix).toBeDefined(); + }); + it('returns a rate hint w/o action for a complex counter metric', () => { const series = [ { @@ -118,6 +133,21 @@ describe('getQueryHints()', () => { }); }); + it('returns a histogram hint with action for a bucket with labels', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_bucket{job="grafana"}', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('Selected metric has buckets.'); + expect(hints![0].fix).toBeDefined(); + }); + it('returns a sum hint when many time series results are returned for a simple metric', () => { const seriesCount = SUM_HINT_THRESHOLD_COUNT; const series = Array.from({ length: seriesCount }, (_) => ({ diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts index 5ab6b8c2832..50bb918b958 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -11,7 +11,7 @@ export function getQueryHints(query: string, series?: any[], datasource?: Promet const hints = []; // ..._bucket metric needs a histogram_quantile() - const histogramMetric = query.trim().match(/^\w+_bucket$/); + const histogramMetric = query.trim().match(/^\w+_bucket$|^\w+_bucket{.*}$/); if (histogramMetric) { const label = 'Selected metric has buckets.'; hints.push({ @@ -53,12 +53,13 @@ export function getQueryHints(query: string, series?: any[], datasource?: Promet } if (counterNameMetric) { - const simpleMetric = query.trim().match(/^\w+$/); + // FixableQuery consists of metric name and optionally label-value pairs. We are not offering fix for complex queries yet. + const fixableQuery = query.trim().match(/^\w+$|^\w+{.*}$/); const verb = certain ? 'is' : 'looks like'; let label = `Selected metric ${verb} a counter.`; let fix: QueryFix | undefined; - if (simpleMetric) { + if (fixableQuery) { fix = { label: 'Consider calculating rate of counter by adding rate().', action: { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts index 56a317419ba..1c2e18f8600 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts @@ -278,4 +278,40 @@ describe('PromQueryModeller', () => { }) ).toBe('metric_a / on(le) metric_b'); }); + it('Can render functions that require a range as a parameter', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'holt_winters', params: ['auto', 0.5, 0.5] }], + }) + ).toBe('holt_winters(metric_a[$__rate_interval], 0.5, 0.5)'); + }); + it('Can render functions that require parameters left of a range', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'quantile_over_time', params: ['auto', 1] }], + }) + ).toBe('quantile_over_time(1, metric_a[$__rate_interval])'); + }); + it('Can render the label_join function', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'label_join', params: ['label_1', ',', 'label_2'] }], + }) + ).toBe('label_join(metric_a, "label_1", ",", "label_2")'); + }); + it('Can render label_join with extra parameters', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'label_join', params: ['label_1', ', ', 'label_2', 'label_3', 'label_4', 'label_5'] }], + }) + ).toBe('label_join(metric_a, "label_1", ", ", "label_2", "label_3", "label_4", "label_5")'); + }); }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts index 96ad85f530e..920fdafa2ae 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts @@ -22,6 +22,8 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase { await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(2)); }); - it('highlihts matching string', async () => { + it('highlights matching string', async () => { const { container } = render(); await openMetricSelect(); const input = screen.getByRole('combobox'); @@ -65,7 +65,7 @@ describe('MetricSelect', () => { await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(1)); }); - it('highlihts multiple matching strings in 1 input row', async () => { + it('highlights multiple matching strings in 1 input row', async () => { const { container } = render(); await openMetricSelect(); const input = screen.getByRole('combobox'); @@ -73,7 +73,7 @@ describe('MetricSelect', () => { await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(2)); }); - it('highlihts multiple matching strings in multiple input rows', async () => { + it('highlights multiple matching strings in multiple input rows', async () => { const { container } = render(); await openMetricSelect(); const input = screen.getByRole('combobox'); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index eccd302aaa4..86e7b676686 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -42,11 +42,11 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) { ); }, - [styles.hightlight] + [styles.highlight] ); return ( @@ -69,7 +69,7 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) { options={state.metrics} onChange={({ value }) => { if (value) { - onChange({ ...query, metric: value, labels: [] }); + onChange({ ...query, metric: value }); } }} /> @@ -82,7 +82,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ select: css` min-width: 125px; `, - hightlight: css` + highlight: css` label: select__match-highlight; background: inherit; padding: inherit; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx index 5df01f76347..6d9bbe328d5 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx @@ -1,5 +1,6 @@ import { PanelData } from '@grafana/data'; -import React, { useEffect, useState } from 'react'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import React, { useEffect, useReducer } from 'react'; import { PrometheusDatasource } from '../../datasource'; import { PromQuery } from '../../types'; @@ -27,20 +28,17 @@ export interface State { */ export function PromQueryBuilderContainer(props: Props) { const { query, onChange, onRunQuery, datasource, data } = props; - const [state, setState] = useState({ expr: query.expr }); + const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr }); // Only rebuild visual query if expr changes from outside useEffect(() => { - if (!state.visQuery || state.expr !== query.expr) { - const result = buildVisualQueryFromString(query.expr || ''); - setState({ visQuery: result.query, expr: query.expr }); - } - }, [query.expr, state.visQuery, state.expr]); + dispatch(exprChanged(query.expr)); + }, [query.expr]); - const onVisQueryChange = (newVisQuery: PromVisualQuery) => { - const rendered = promQueryModeller.renderQuery(newVisQuery); - onChange({ ...query, expr: rendered }); - setState({ visQuery: newVisQuery, expr: rendered }); + const onVisQueryChange = (visQuery: PromVisualQuery) => { + const expr = promQueryModeller.renderQuery(visQuery); + dispatch(visualQueryChange({ visQuery, expr })); + onChange({ ...props.query, expr: expr }); }; if (!state.visQuery) { @@ -60,3 +58,23 @@ export function PromQueryBuilderContainer(props: Props) { ); } + +const stateSlice = createSlice({ + name: 'prom-builder-container', + initialState: { expr: '' } as State, + reducers: { + visualQueryChange: (state, action: PayloadAction<{ visQuery: PromVisualQuery; expr: string }>) => { + state.expr = action.payload.expr; + state.visQuery = action.payload.visQuery; + }, + exprChanged: (state, action: PayloadAction) => { + if (!state.visQuery || state.expr !== action.payload) { + state.expr = action.payload; + const parseResult = buildVisualQueryFromString(action.payload); + state.visQuery = parseResult.query; + } + }, + }, +}); + +const { visualQueryChange, exprChanged } = stateSlice.actions; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx new file mode 100644 index 00000000000..ac6b5132951 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PromQuery } from '../../types'; +import { getQueryWithDefaults } from '../types'; +import { CoreApp } from '@grafana/data'; +import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; +import { selectOptionInTest } from '@grafana/ui'; + +describe('PromQueryBuilderOptions', () => { + it('Can change query type', async () => { + const { props } = setup(); + + screen.getByTitle('Click to edit options').click(); + expect(screen.getByLabelText('Range')).toBeChecked(); + + screen.getByLabelText('Instant').click(); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + instant: true, + range: false, + exemplar: false, + }); + }); + + it('Legend format default to Auto', async () => { + setup(); + expect(screen.getByText('Legend: Auto')).toBeInTheDocument(); + }); + + it('Can change legend format to verbose', async () => { + const { props } = setup(); + + screen.getByTitle('Click to edit options').click(); + + let legendModeSelect = screen.getByText('Auto').parentElement!; + legendModeSelect.click(); + + await selectOptionInTest(legendModeSelect as HTMLElement, 'Verbose'); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + legendFormat: '', + }); + }); + + it('Can change legend format to custom', async () => { + const { props } = setup(); + + screen.getByTitle('Click to edit options').click(); + + let legendModeSelect = screen.getByText('Auto').parentElement!; + legendModeSelect.click(); + + await selectOptionInTest(legendModeSelect as HTMLElement, 'Custom'); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + legendFormat: '{{label_name}}', + }); + }); +}); + +function setup(queryOverrides: Partial = {}) { + const props = { + query: { + ...getQueryWithDefaults({ refId: 'A' } as PromQuery, CoreApp.PanelEditor), + queryOverrides, + }, + onRunQuery: jest.fn(), + onChange: jest.fn(), + }; + + const { container } = render(); + return { container, props }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx index 64f1a5ea653..33bcc9ae8e4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx @@ -6,6 +6,7 @@ import { QueryOptionGroup } from '../shared/QueryOptionGroup'; import { PromQuery } from '../../types'; import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from '../../components/PromQueryEditor'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; +import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor'; export interface Props { query: PromQuery; @@ -15,18 +16,11 @@ export interface Props { } export const PromQueryBuilderOptions = React.memo(({ query, app, onChange, onRunQuery }) => { - const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0]; - const onChangeFormat = (value: SelectableValue) => { onChange({ ...query, format: value.value }); onRunQuery(); }; - const onLegendFormatChanged = (evt: React.FocusEvent) => { - onChange({ ...query, legendFormat: evt.currentTarget.value }); - onRunQuery(); - }; - const onChangeStep = (evt: React.FocusEvent) => { onChange({ ...query, interval: evt.currentTarget.value }); onRunQuery(); @@ -46,15 +40,14 @@ export const PromQueryBuilderOptions = React.memo(({ query, app, onChange onRunQuery(); }; + const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0]; + const queryTypeValue = getQueryTypeValue(query); + const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryTypeValue)!.label; + return ( - - - - + + (({ query, app, onChange defaultValue={query.interval} /> - + )} + {mode !== LegendFormatMode.Custom && ( + { + setAsyncServiceNameValue({ + value: v, + }); onChange({ ...query, serviceName: v?.value || undefined, }); }} placeholder="Select a service" - onOpenMenu={fetchServiceNameOptions} isClearable + defaultOptions onKeyDown={onKeyDown} + aria-label={'select-service-name'} /> -