diff --git a/.betterer.results b/.betterer.results index 5342b789f3a..428114855c9 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2752,11 +2752,6 @@ exports[`better eslint`] = { [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Using localeCompare() can cause performance issues when sorting large datasets. Consider using Intl.Collator for better performance when sorting arrays, or add an eslint-disable comment if sorting a small, known dataset.", "1"] ], - "public/app/features/teams/CreateTeam.tsx:5381": [ - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"] - ], "public/app/features/teams/TeamSettings.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], @@ -3501,12 +3496,6 @@ exports[`better eslint`] = { "public/app/plugins/datasource/influxdb/response_parser.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/datasource/jaeger/_importedDependencies/model/transform-trace-data.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/plugins/datasource/jaeger/_importedDependencies/types/index.tsx:5381": [ - [0, 0, 0, "Do not re-export imported variable (\`./trace\`)", "0"] - ], "public/app/plugins/datasource/jaeger/datasource.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -3933,9 +3922,6 @@ exports[`better eslint`] = { "public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], - "public/app/plugins/panel/table/table-new/cells/AutoCellOptionsEditor.tsx:5381": [ - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] - ], "public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] @@ -3953,6 +3939,9 @@ exports[`better eslint`] = { [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] ], + "public/app/plugins/panel/table/table-new/cells/TextWrapOptionsEditor.tsx:5381": [ + [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] + ], "public/app/plugins/panel/table/table-new/migrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/.drone.star b/.drone.star index a430359e27d..a563ee0682a 100644 --- a/.drone.star +++ b/.drone.star @@ -8,7 +8,6 @@ This module returns a Drone configuration including pipelines and secrets. """ load("scripts/drone/events/main.star", "main_pipelines") -load("scripts/drone/events/pr.star", "pr_pipelines") load( "scripts/drone/events/release.star", "publish_artifacts_pipelines", @@ -28,7 +27,6 @@ load("scripts/drone/vault.star", "secrets") def main(_ctx): return ( - pr_pipelines() + main_pipelines() + rrc_patch_pipelines() + publish_image_pipelines_public() + diff --git a/.drone.yml b/.drone.yml index 345aae7d4bd..52bdd9dc125 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,498 +8,6 @@ image_pull_secrets: - gcr - gar kind: pipeline -name: pr-verify-drone -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - echo $DRONE_RUNNER_NAME - image: alpine:3.21.3 - name: identify-runner -- commands: - - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd - depends_on: [] - environment: - CGO_ENABLED: 0 - image: golang:1.24.5-alpine - name: compile-build-cmd -- commands: - - ./bin/build verify-drone - depends_on: - - compile-build-cmd - image: byrnedo/alpine-curl:0.1.8 - name: lint-drone -trigger: - event: - - pull_request - paths: - exclude: - - docs/** - - '*.md' - include: - - scripts/drone/** - - .drone.yml - - .drone.star -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker ---- -clone: - retries: 3 -depends_on: [] -environment: - EDITION: oss -image_pull_secrets: -- gcr -- gar -kind: pipeline -name: pr-verify-starlark -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - echo $DRONE_RUNNER_NAME - image: alpine:3.21.3 - name: identify-runner -- commands: - - go install github.com/bazelbuild/buildtools/buildifier@latest - - buildifier --lint=warn -mode=check -r . - depends_on: [] - image: golang:1.24.5-alpine - name: lint-starlark -trigger: - event: - - pull_request - paths: - exclude: - - docs/** - - '*.md' - include: - - scripts/drone/** - - .drone.star -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker ---- -clone: - retries: 3 -depends_on: [] -environment: - EDITION: oss -image_pull_secrets: -- gcr -- gar -kind: pipeline -name: pr-build-e2e -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - echo $(/usr/bin/github-app-external-token) > /github-app/token - environment: - GITHUB_APP_ID: - from_secret: github-app-app-id - GITHUB_APP_INSTALLATION_ID: - from_secret: github-app-installation-id - GITHUB_APP_PRIVATE_KEY: - from_secret: github-app-private-key - failure: ignore - image: us-docker.pkg.dev/grafanalabs-global/docker-deployment-tools-prod/github-app-secret-writer:2024-11-05-v11688112090.1-83920c59 - name: github-app-generate-token - volumes: - - name: github-app - path: /github-app -- commands: - - echo $DRONE_RUNNER_NAME - image: alpine:3.21.3 - name: identify-runner -- commands: - - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.1.2/grabpl - - chmod +x bin/grabpl - image: byrnedo/alpine-curl:0.1.8 - name: grabpl -- commands: - - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd - depends_on: [] - environment: - CGO_ENABLED: 0 - image: golang:1.24.5-alpine - name: compile-build-cmd -- commands: - - '# It is required that code generated from Thema/CUE be committed and in sync - with its inputs.' - - '# The following command will fail if running code generators produces any diff - in output.' - - apk add --update make - - CODEGEN_VERIFY=1 make gen-cue - depends_on: [] - image: golang:1.24.5-alpine - name: verify-gen-cue -- commands: - - '# It is required that generated jsonnet is committed and in sync with its inputs.' - - '# The following command will fail if running code generators produces any diff - in output.' - - apk add --update make - - CODEGEN_VERIFY=1 make gen-jsonnet - depends_on: [] - image: golang:1.24.5-alpine - name: verify-gen-jsonnet -- commands: - - yarn install --immutable || yarn install --immutable - depends_on: [] - image: node:22.16.0-alpine - name: yarn-install -- commands: - - apk add --update jq bash - - yarn packages:build - - yarn packages:pack - - ./scripts/validate-npm-packages.sh - depends_on: - - yarn-install - environment: - NODE_OPTIONS: --max_old_space_size=8192 - image: node:22.16.0-alpine - name: build-frontend-packages -- failure: ignore - image: grafana/drone-downstream - name: trigger-enterprise-downstream - settings: - params: - - SOURCE_BUILD_NUMBER=${DRONE_COMMIT} - - SOURCE_COMMIT=${DRONE_COMMIT} - - OSS_PULL_REQUEST=${DRONE_PULL_REQUEST} - repositories: - - grafana/grafana-enterprise@${DRONE_SOURCE_BRANCH} - server: https://drone.grafana.net - token: - from_secret: drone_token -- commands: - - wget -qO- https://github.com/dagger/dagger/releases/download/v0.18.8/dagger_v0.18.8_linux_amd64.tar.gz - | tar zx -C /bin - - apk add docker - - docker run --privileged --rm tonistiigi/binfmt:qemu-v7.0.0-28 --version - - docker run --privileged --rm tonistiigi/binfmt:qemu-v7.0.0-28 --uninstall 'qemu-*' - - docker run --privileged --rm tonistiigi/binfmt:qemu-v7.0.0-28 --install all - - go run ./pkg/build/cmd artifacts -a targz:grafana:linux/amd64 -a targz:grafana:linux/arm64 - -a targz:grafana:linux/arm/v7 -a docker:grafana:linux/amd64 -a docker:grafana:linux/amd64:ubuntu - -a docker:grafana:linux/arm64 -a docker:grafana:linux/arm64:ubuntu -a docker:grafana:linux/arm/v7 - -a docker:grafana:linux/arm/v7:ubuntu --yarn-cache=$$YARN_CACHE_FOLDER --build-id=$$DRONE_BUILD_NUMBER - --ubuntu-base=ubuntu-base --alpine-base=alpine-base --tag-format='{{ .version_base - }}-{{ .buildID }}-{{ .arch }}' --ubuntu-tag-format='{{ .version_base }}-{{ .buildID - }}-ubuntu-{{ .arch }}' --verify='false' --grafana-dir=$$PWD > packages.txt - - find ./dist -name '*docker*.tar.gz' -type f | xargs -n1 docker load -i - depends_on: - - yarn-install - environment: - _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: - from_secret: dagger_token - image: golang:1.24.5-alpine - name: rgm-package - pull: always - volumes: - - name: docker - path: /var/run/docker.sock -- commands: - - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana - depends_on: - - rgm-package - environment: - DOCKER_PASSWORD: - from_secret: docker_password - DOCKER_USER: - from_secret: docker_username - GITHUB_APP_ID: "329617" - GITHUB_APP_INSTALLATION_ID: "37346161" - GITHUB_APP_PRIVATE_KEY: - from_secret: delivery-bot-app-private-key - failure: ignore - image: google/cloud-sdk:431.0.0 - name: publish-images-grafana - volumes: - - name: docker - path: /var/run/docker.sock -- commands: - - yarn e2e:plugin:build - depends_on: - - yarn-install - environment: - NODE_OPTIONS: --max_old_space_size=8192 - image: node:22.16.0-alpine - name: build-test-plugins -- commands: - - apk add --update tar bash - - mkdir grafana - - tar --strip-components=1 -xvf ./dist/*amd64.tar.gz -C grafana - - cp -r devenv scripts tools grafana && cd grafana && ./scripts/grafana-server/start-server - depends_on: - - rgm-package - detach: true - environment: - GF_APP_MODE: development - GF_SERVER_HTTP_PORT: "3001" - GF_SERVER_ROUTER_LOGGING: "1" - image: alpine:3.21.3 - name: grafana-server -- commands: - - GITHUB_TOKEN=$(cat /github-app/token) - - cd / - - ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH} - depends_on: - - grafana-server - - github-app-generate-token - environment: - AZURE_SP_APP_ID: - from_secret: azure_sp_app_id - AZURE_SP_PASSWORD: - from_secret: azure_sp_app_pw - AZURE_TENANT: - from_secret: azure_tenant - CYPRESS_CI: "true" - HOST: grafana-server - image: us-docker.pkg.dev/grafanalabs-dev/docker-oss-plugin-partnerships-dev/e2e-14.3.2:1.0.0 - name: end-to-end-tests-cloud-plugins-suite-azure - volumes: - - name: github-app - path: /github-app - when: - paths: - include: - - pkg/tsdb/azuremonitor/** - - public/app/plugins/datasource/azuremonitor/** - - e2e/cloud-plugins-suite/azure-monitor.spec.ts - repo: - - grafana/grafana -- commands: - - npx wait-on@7.0.1 http://$HOST:$PORT - - yarn playwright install --with-deps chromium - - GRAFANA_URL=http://$HOST:$PORT yarn e2e:playwright --grep @plugins - depends_on: - - grafana-server - - build-test-plugins - environment: - HOST: grafana-server - PORT: "3001" - PROV_DIR: /grafana/scripts/grafana-server/tmp/conf/provisioning - image: node:22-bookworm - name: playwright-plugin-e2e -- commands: - - apt-get update - - apt-get install -yq zip - - printenv GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY > /tmp/gcpkey_upload_artifacts.json - - gcloud auth activate-service-account --key-file=/tmp/gcpkey_upload_artifacts.json - - gsutil cp -r ./playwright-report/. gs://releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report - - export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html - - "echo \"E2E Playwright report uploaded to: \n $${E2E_PLAYWRIGHT_REPORT_URL}\"" - depends_on: - - playwright-plugin-e2e - environment: - GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY: - from_secret: gcp_upload_artifacts_key - failure: ignore - image: google/cloud-sdk:431.0.0 - name: playwright-e2e-report-upload - when: - status: - - success - - failure -- commands: - - GITHUB_TOKEN=$(cat /github-app/token) - - if [ ! -d ./playwright-report/trace ]; then echo 'all tests passed'; exit 0; fi - - export E2E_PLAYWRIGHT_REPORT_URL=https://storage.googleapis.com/releng-pipeline-artifacts-dev/${DRONE_BUILD_NUMBER}/playwright-report/index.html - - 'curl -L -X POST https://api.github.com/repos/grafana/grafana/issues/${DRONE_PULL_REQUEST}/comments - -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_TOKEN}" - -H "X-GitHub-Api-Version: 2022-11-28" -d "{\"body\":\"❌ Failed to run Playwright - plugin e2e tests.

Click [here]($${E2E_PLAYWRIGHT_REPORT_URL}) to - browse the Playwright report and trace viewer.
For information on how to - run Playwright tests locally, refer to the [Developer guide](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#to-run-the-playwright-tests). - \"}"' - depends_on: - - playwright-e2e-report-upload - - github-app-generate-token - failure: ignore - image: byrnedo/alpine-curl:0.1.8 - name: playwright-e2e-report-post-link - volumes: - - name: github-app - path: /github-app - when: - status: - - success - - failure -- commands: - - export GITHUB_TOKEN=$(cat /github-app/token) - - if [ -z `find ./e2e -type f -name *spec.ts.mp4` ]; then echo 'no e2e videos found - from remaining tests'; exit 0; fi - - apt-get update - - apt-get install -yq zip - - printenv GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY > /tmp/gcpkey_upload_artifacts.json - - gcloud auth activate-service-account --key-file=/tmp/gcpkey_upload_artifacts.json - - find ./e2e -type f -name "*spec.ts.mp4" | zip e2e/videos.zip -@ - - gsutil cp e2e/videos.zip gs://$${E2E_TEST_ARTIFACTS_BUCKET}/${DRONE_BUILD_NUMBER}/artifacts/videos/videos.zip - - export E2E_ARTIFACTS_VIDEO_ZIP=https://storage.googleapis.com/$${E2E_TEST_ARTIFACTS_BUCKET}/${DRONE_BUILD_NUMBER}/artifacts/videos/videos.zip - - 'echo "E2E Test artifacts uploaded to: $${E2E_ARTIFACTS_VIDEO_ZIP}"' - - 'curl -X POST https://api.github.com/repos/${DRONE_REPO}/statuses/${DRONE_COMMIT_SHA} - -H "Authorization: token $${GITHUB_TOKEN}" -d "{\"state\":\"success\",\"target_url\":\"$${E2E_ARTIFACTS_VIDEO_ZIP}\", - \"description\": \"Click on the details to download e2e recording videos\", \"context\": - \"e2e_artifacts\"}"' - depends_on: - - end-to-end-tests-cloud-plugins-suite-azure - - playwright-plugin-e2e - - github-app-generate-token - environment: - E2E_TEST_ARTIFACTS_BUCKET: releng-pipeline-artifacts-dev - GCP_GRAFANA_UPLOAD_ARTIFACTS_KEY: - from_secret: gcp_upload_artifacts_key - failure: ignore - image: google/cloud-sdk:431.0.0 - name: e2e-tests-artifacts-upload - volumes: - - name: github-app - path: /github-app - when: - status: - - success - - failure -- commands: - - yarn storybook:build - - ./bin/build verify-storybook - depends_on: - - rgm-package - - build-frontend-packages - environment: - NODE_OPTIONS: --max_old_space_size=4096 - image: node:22.16.0-alpine - name: build-storybook - when: - paths: - include: - - packages/grafana-ui/** -- commands: - - npx wait-on@7.0.1 http://$HOST:$PORT - - pa11y-ci --config e2e/pa11yci.conf.js - depends_on: - - grafana-server - environment: - GRAFANA_MISC_STATS_API_KEY: - from_secret: grafana_misc_stats_api_key - HOST: grafana-server - NO_THRESHOLDS: "false" - PORT: 3001 - failure: always - image: grafana/docker-puppeteer:1.1.0 - name: test-a11y-frontend -trigger: - event: - - pull_request - paths: - exclude: - - '*.md' - - docs/** - - latest.json -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker -- name: github-app - temp: {} ---- -clone: - retries: 3 -depends_on: [] -environment: - EDITION: oss -image_pull_secrets: -- gcr -- gar -kind: pipeline -name: pr-docs -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - echo $DRONE_RUNNER_NAME - image: alpine:3.21.3 - name: identify-runner -- commands: - - yarn install --immutable || yarn install --immutable - depends_on: [] - image: node:22.16.0-alpine - name: yarn-install -- commands: - - yarn run prettier:checkDocs - depends_on: - - yarn-install - environment: - NODE_OPTIONS: --max_old_space_size=8192 - image: node:22.16.0-alpine - name: lint-docs -- commands: - - mkdir -p /hugo/content/docs/grafana/latest - - 'echo -e ''---\nredirectURL: /docs/grafana/latest/\ntype: redirect\nversioned: - true\n---\n'' > /hugo/content/docs/grafana/_index.md' - - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ - - cd /hugo && make prod - image: grafana/docs-base:latest - name: build-docs-website - pull: always -- commands: - - '# It is required that code generated from Thema/CUE be committed and in sync - with its inputs.' - - '# The following command will fail if running code generators produces any diff - in output.' - - apk add --update make - - CODEGEN_VERIFY=1 make gen-cue - depends_on: [] - image: golang:1.24.5-alpine - name: verify-gen-cue -trigger: - event: - - pull_request - paths: - include: - - '*.md' - - docs/** - - packages/**/*.md - - latest.json - repo: - - grafana/grafana -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker ---- -clone: - retries: 3 -depends_on: [] -environment: - EDITION: oss -image_pull_secrets: -- gcr -- gar -kind: pipeline name: main-docs node: type: no-parallel @@ -2049,264 +1557,6 @@ image_pull_secrets: - gcr - gar kind: pipeline -name: rgm-nightly-build -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - wget -qO- https://github.com/dagger/dagger/releases/download/v0.18.8/dagger_v0.18.8_linux_amd64.tar.gz - | tar zx -C /bin - - apk add docker - - export GRAFANA_DIR=$$(pwd) - - export GITHUB_TOKEN=$(cat /github-app/token) - - ./pkg/build/daggerbuild/scripts/drone_build_nightly_grafana.sh - environment: - _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: - from_secret: dagger_token - ALPINE_BASE: alpine:3.21.3 - CDN_DESTINATION: - from_secret: rgm_cdn_destination - DESTINATION: - from_secret: destination - DOCKER_PASSWORD: - from_secret: docker_password - DOCKER_USERNAME: - from_secret: docker_username - DOWNLOADS_DESTINATION: - from_secret: rgm_downloads_destination - GCOM_API_KEY: - from_secret: grafana_api_key - GCP_KEY_BASE64: - from_secret: gcp_key_base64 - GPG_PASSPHRASE: - from_secret: packages_gpg_passphrase - GPG_PRIVATE_KEY: - from_secret: packages_gpg_private_key - GPG_PUBLIC_KEY: - from_secret: packages_gpg_public_key - NPM_TOKEN: - from_secret: npm_token - STORYBOOK_DESTINATION: - from_secret: rgm_storybook_destination - UBUNTU_BASE: ubuntu:22.04 - image: golang:1.24.5-alpine - name: rgm-build - pull: always - volumes: - - name: docker - path: /var/run/docker.sock - - name: github-app - path: /github-app -- commands: - - mkdir -p $${DESTINATION}/$${DRONE_BUILD_EVENT} - - printenv GCP_KEY_BASE64 | base64 -d > /tmp/key.json - - gcloud auth activate-service-account --key-file=/tmp/key.json - - gcloud storage cp -r $${DRONE_WORKSPACE}/dist/* $${DESTINATION}/$${DRONE_BUILD_EVENT} - depends_on: - - rgm-build - environment: - _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: - from_secret: dagger_token - CDN_DESTINATION: - from_secret: rgm_cdn_destination - DESTINATION: - from_secret: destination - DOCKER_PASSWORD: - from_secret: docker_password - DOCKER_USERNAME: - from_secret: docker_username - DOWNLOADS_DESTINATION: - from_secret: rgm_downloads_destination - GCOM_API_KEY: - from_secret: grafana_api_key - GCP_KEY_BASE64: - from_secret: gcp_key_base64 - GPG_PASSPHRASE: - from_secret: packages_gpg_passphrase - GPG_PRIVATE_KEY: - from_secret: packages_gpg_private_key - GPG_PUBLIC_KEY: - from_secret: packages_gpg_public_key - NPM_TOKEN: - from_secret: npm_token - STORYBOOK_DESTINATION: - from_secret: rgm_storybook_destination - image: google/cloud-sdk:alpine - name: rgm-copy -trigger: - cron: - include: - - nightly-release - event: - include: - - cron -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker ---- -clone: - retries: 3 -depends_on: -- rgm-nightly-build -image_pull_secrets: -- gcr -- gar -kind: pipeline -name: rgm-nightly-publish -node: - type: no-parallel -platform: - arch: amd64 - os: linux -services: [] -steps: -- commands: - - mkdir -p $${DRONE_WORKSPACE}/dist - - printenv GCP_KEY_BASE64 | base64 -d > /tmp/key.json - - gcloud auth activate-service-account --key-file=/tmp/key.json - - gcloud storage cp -r $${DESTINATION}/$${DRONE_BUILD_EVENT}/*_$${DRONE_BUILD_NUMBER}_* - $${DRONE_WORKSPACE}/dist - environment: - _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: - from_secret: dagger_token - CDN_DESTINATION: - from_secret: rgm_cdn_destination - DESTINATION: - from_secret: destination - DOCKER_PASSWORD: - from_secret: docker_password - DOCKER_USERNAME: - from_secret: docker_username - DOWNLOADS_DESTINATION: - from_secret: rgm_downloads_destination - GCOM_API_KEY: - from_secret: grafana_api_key - GCP_KEY_BASE64: - from_secret: gcp_key_base64 - GPG_PASSPHRASE: - from_secret: packages_gpg_passphrase - GPG_PRIVATE_KEY: - from_secret: packages_gpg_private_key - GPG_PUBLIC_KEY: - from_secret: packages_gpg_public_key - NPM_TOKEN: - from_secret: npm_token - STORYBOOK_DESTINATION: - from_secret: rgm_storybook_destination - image: google/cloud-sdk:alpine - name: rgm-copy -- commands: - - wget -qO- https://github.com/dagger/dagger/releases/download/v0.18.8/dagger_v0.18.8_linux_amd64.tar.gz - | tar zx -C /bin - - apk add docker - - export GRAFANA_DIR=$$(pwd) - - export GITHUB_TOKEN=$(cat /github-app/token) - - ./pkg/build/daggerbuild/scripts/drone_publish_nightly_grafana.sh - depends_on: - - rgm-copy - environment: - _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: - from_secret: dagger_token - ALPINE_BASE: alpine:3.21.3 - CDN_DESTINATION: - from_secret: rgm_cdn_destination - DESTINATION: - from_secret: destination - DOCKER_PASSWORD: - from_secret: docker_password - DOCKER_USERNAME: - from_secret: docker_username - DOWNLOADS_DESTINATION: - from_secret: rgm_downloads_destination - GCOM_API_KEY: - from_secret: grafana_api_key - GCP_KEY_BASE64: - from_secret: gcp_key_base64 - GPG_PASSPHRASE: - from_secret: packages_gpg_passphrase - GPG_PRIVATE_KEY: - from_secret: packages_gpg_private_key - GPG_PUBLIC_KEY: - from_secret: packages_gpg_public_key - NPM_TOKEN: - from_secret: npm_token - STORYBOOK_DESTINATION: - from_secret: rgm_storybook_destination - UBUNTU_BASE: ubuntu:22.04 - image: golang:1.24.5-alpine - name: rgm-publish - pull: always - volumes: - - name: docker - path: /var/run/docker.sock - - name: github-app - path: /github-app -- depends_on: - - rgm-publish - image: us.gcr.io/kubernetes-dev/package-publish:latest - name: publish-deb - privileged: true - settings: - access_key_id: - from_secret: packages_access_key_id - gpg_passphrase: - from_secret: packages_gpg_passphrase - gpg_private_key: - from_secret: packages_gpg_private_key - gpg_public_key: - from_secret: packages_gpg_public_key - package_path: file:///drone/src/dist/*.deb - secret_access_key: - from_secret: packages_secret_access_key - service_account_json: - from_secret: packages_service_account - target_bucket: grafana-packages -- depends_on: - - rgm-publish - image: us.gcr.io/kubernetes-dev/package-publish:latest - name: publish-rpm - privileged: true - settings: - access_key_id: - from_secret: packages_access_key_id - gpg_passphrase: - from_secret: packages_gpg_passphrase - gpg_private_key: - from_secret: packages_gpg_private_key - gpg_public_key: - from_secret: packages_gpg_public_key - package_path: file:///drone/src/dist/*.rpm - secret_access_key: - from_secret: packages_secret_access_key - service_account_json: - from_secret: packages_service_account - target_bucket: grafana-packages -trigger: - cron: - include: - - nightly-release - event: - include: - - cron -type: docker -volumes: -- host: - path: /var/run/docker.sock - name: docker ---- -clone: - retries: 3 -depends_on: [] -image_pull_secrets: -- gcr -- gar -kind: pipeline name: rgm-promotion node: type: no-parallel @@ -2623,8 +1873,3 @@ get: path: secret/data/common/gcr kind: secret name: gcr_credentials ---- -kind: signature -hmac: aef043aae7394d3160a7147c8b57599bf1a2f4ba5c596ffb795a0e6a049c73a6 - -... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b3ab77bcd19..f26cfd876aa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -72,9 +72,12 @@ /hack/ @grafana/grafana-app-platform-squad /.air.toml @macabu +# Git Sync / App Platform Provisioning /pkg/apis/provisioning @grafana/grafana-git-ui-sync-team /public/app/features/provisioning @grafana/grafana-git-ui-sync-team /pkg/registry/apis/provisioning @grafana/grafana-git-ui-sync-team +/pkg/tests/apis/provisioning @grafana/grafana-git-ui-sync-team +# Git Sync frontend owned by frontent team as a whole. /apps/alerting/ @grafana/alerting-backend /apps/dashboard/ @grafana/grafana-app-platform-squad @grafana/dashboards-squad diff --git a/.github/workflows/alerting-swagger-gen.yml b/.github/workflows/alerting-swagger-gen.yml index c06304b38a3..19476764b69 100644 --- a/.github/workflows/alerting-swagger-gen.yml +++ b/.github/workflows/alerting-swagger-gen.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 2 persist-credentials: false - name: Set go version - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: go.mod - name: Build swagger diff --git a/.github/workflows/alerting-update-module.yml b/.github/workflows/alerting-update-module.yml index dce80d065e4..0b8d5730618 100644 --- a/.github/workflows/alerting-update-module.yml +++ b/.github/workflows/alerting-update-module.yml @@ -29,7 +29,7 @@ jobs: fi - name: Setup Go - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # 5.3.0 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # 5.5.0 with: "go-version-file": "go.mod" diff --git a/.github/workflows/backend-code-checks.yml b/.github/workflows/backend-code-checks.yml index b95257d99c3..bf7252a95f5 100644 --- a/.github/workflows/backend-code-checks.yml +++ b/.github/workflows/backend-code-checks.yml @@ -27,7 +27,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: # Explicitly set Go version to 1.24.1 to ensure consistent OpenAPI spec generation # The crypto/x509 package has additional fields in Go 1.24.1 that affect the generated specs diff --git a/.github/workflows/backend-unit-tests.yml b/.github/workflows/backend-unit-tests.yml index 1c839432829..0d3c468b21f 100644 --- a/.github/workflows/backend-unit-tests.yml +++ b/.github/workflows/backend-unit-tests.yml @@ -57,7 +57,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod - name: Run unit tests @@ -92,7 +92,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod - name: Setup Enterprise diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a07cb222898..eea0caa319c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: - if: matrix.language == 'go' name: Set go version - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: go.mod diff --git a/.github/workflows/core-plugins-build-and-release.yml b/.github/workflows/core-plugins-build-and-release.yml index c3c934ea2b6..ec8b1db511f 100644 --- a/.github/workflows/core-plugins-build-and-release.yml +++ b/.github/workflows/core-plugins-build-and-release.yml @@ -101,7 +101,7 @@ jobs: echo "has_backend=false" >> "$GITHUB_OUTPUT" fi - name: Setup golang environment - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 if: steps.check_backend.outputs.has_backend == 'true' with: go-version-file: go.mod diff --git a/.github/workflows/feature-toggles-ci.yml b/.github/workflows/feature-toggles-ci.yml index ab1aa9b2dca..880810a333a 100644 --- a/.github/workflows/feature-toggles-ci.yml +++ b/.github/workflows/feature-toggles-ci.yml @@ -22,7 +22,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: 'go.mod' cache: true diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index 78788f3570c..f870c16a847 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v5.5.0 with: go-version-file: ./go.mod - name: golangci-lint diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml index b54bb968b7f..2ca46dd1db2 100644 --- a/.github/workflows/issue-opened.yml +++ b/.github/workflows/issue-opened.yml @@ -88,7 +88,6 @@ jobs: private-key: ${{ env.GITHUB_APP_PRIVATE_KEY }} permission-members: read permission-issues: write - - name: Check if member of grafana org id: check-if-grafana-org-member continue-on-error: true @@ -96,6 +95,13 @@ jobs: env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} ACTOR: ${{ github.actor }} + - name: Checkout + if: steps.check-if-grafana-org-member.outputs.is_grafana_org_member != 'true' && github.event.issue.author_association != 'MEMBER' && github.event.issue.author_association != 'OWNER' + uses: actions/checkout@v4 # v4.2.2 + with: + persist-credentials: false + sparse-checkout: | + .github/workflows/auto-triager - name: Send issue to the auto triager action id: auto_triage if: steps.check-if-grafana-org-member.outputs.is_grafana_org_member != 'true' && github.event.issue.author_association != 'MEMBER' && github.event.issue.author_association != 'OWNER' diff --git a/.github/workflows/pr-dependabot-update-go-workspace.yml b/.github/workflows/pr-dependabot-update-go-workspace.yml index 82169bd69ac..b0dbc68c266 100644 --- a/.github/workflows/pr-dependabot-update-go-workspace.yml +++ b/.github/workflows/pr-dependabot-update-go-workspace.yml @@ -45,7 +45,7 @@ jobs: persist-credentials: false - name: Set go version - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: go.mod diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index bbae7d86c65..5c9ad0e71d4 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -44,8 +44,6 @@ jobs: runs-on: ubuntu-latest-16-cores permissions: contents: read - outputs: - artifact: ${{ steps.artifact.outputs.artifact }} steps: - uses: actions/checkout@v4 with: @@ -57,7 +55,7 @@ jobs: - uses: actions/cache@v4 id: cache with: - key: "build-grafana-${{ runner.os }}-${{ hashFiles('yarn.lock', 'public/*', 'packages/*', 'pkg/**/*.go', '**/go.mod', '**/go.sum', '!**_test.go', '!**.test.ts', '!**.test.tsx') }}" + key: "build-grafana-${{ runner.os }}-${{ hashFiles('yarn.lock', 'public/*', 'packages/*', 'pkg/**/*.go', '**/go.mod', '**/go.sum', '!**_test.go', '!**.test.ts', '!**.test.tsx', 'Dockerfile') }}" path: | build-dir @@ -67,15 +65,16 @@ jobs: uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e with: verb: run - args: go run ./pkg/build/cmd artifacts -a targz:grafana:linux/amd64 --grafana-dir="${PWD}" > out.txt + args: go run ./pkg/build/cmd artifacts -a targz:grafana:linux/amd64 -a docker:grafana:linux/amd64 --grafana-dir="${PWD}" > out.txt - name: Cat built artifact if: steps.cache.outputs.cache-hit != 'true' run: cat out.txt - - name: Move built artifact + - name: Move built artifacts if: steps.cache.outputs.cache-hit != 'true' run: | mkdir -p build-dir - mv "$(cat out.txt)" build-dir/grafana.tar.gz + mv "$(grep 'grafana_.*tar.gz$' out.txt | grep -Fv -m1 'docker')" build-dir/grafana.tar.gz + mv "$(grep 'grafana_.*docker.tar.gz$' out.txt)" build-dir/grafana.docker.tar.gz # If cache hit, validate the artifact is present - name: Validate artifact @@ -90,14 +89,20 @@ jobs: run: echo "artifact=grafana-server-${{github.run_number}}" >> "$GITHUB_OUTPUT" id: artifact - - name: Upload artifact + - name: Upload grafana.tar.gz uses: actions/upload-artifact@v4 - id: upload with: retention-days: 1 - name: ${{ steps.artifact.outputs.artifact }} + name: grafana-tar-gz path: build-dir/grafana.tar.gz + - name: Upload grafana docker tarball + uses: actions/upload-artifact@v4 + with: + retention-days: 1 + name: grafana-docker-tar-gz + path: build-dir/grafana.docker.tar.gz + # TODO: we won't need this when we only have playwright build-e2e-runner: needs: detect-changes @@ -113,7 +118,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod cache: ${{ !github.event.pull_request.head.repo.fork }} @@ -131,6 +136,66 @@ jobs: name: ${{ steps.artifact.outputs.artifact }} path: e2e-runner + push-docker-image: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + needs: + - build-grafana + steps: + - id: vault-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@main + with: + repo_secrets: | + GRAFANA_DELIVERY_BOT_APP_PEM=delivery-bot-app:PRIVATE_KEY + - name: Generate token + id: generate_token + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a + with: + app_id: ${{ vars.DELIVERY_BOT_APP_ID }} + private_key: ${{ env.GRAFANA_DELIVERY_BOT_APP_PEM }} + repositories: '["grafana"]' + permissions: '{"checks": "write"}' + - uses: grafana/shared-workflows/actions/login-to-gar@main + id: login-to-gar + with: + registry: 'us-docker.pkg.dev' + environment: 'dev' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: grafana-docker-tar-gz + path: . + - name: Load & Push Docker image + env: + BUILD_ID: ${{ github.run_id }} + run: | + set -euo pipefail + LOADED_IMAGE_NAME=$(docker load -i grafana.docker.tar.gz | sed 's/Loaded image: //g') + VERSION=$(echo "${LOADED_IMAGE_NAME}" | cut -d ':' -f 2 | cut -d '-' -f 1) + DOCKER_IMAGE="us-docker.pkg.dev/grafanalabs-dev/docker-grafana-dev/grafana:${VERSION}-${BUILD_ID}" + docker tag "${LOADED_IMAGE_NAME}" "${DOCKER_IMAGE}" + docker push "${DOCKER_IMAGE}" + echo "IMAGE=${DOCKER_IMAGE}" >> "$GITHUB_ENV" + - name: Add PR status check + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/grafana/grafana/check-runs \ + -f "name=${IMAGE}" \ + -f "head_sha=${SHA}" \ + -f 'status=completed' \ + -f 'conclusion=neutral' \ + -f 'output[title]=Docker image' \ + -f "output[summary]=${IMAGE}" \ + -f "output[text]=${IMAGE}" + run-e2e-tests: needs: - build-grafana @@ -166,7 +231,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@v4 with: - name: ${{ needs.build-grafana.outputs.artifact }} + name: grafana-tar-gz - uses: actions/download-artifact@v4 with: name: ${{ needs.build-e2e-runner.outputs.artifact }} @@ -242,7 +307,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@v4 with: - name: ${{ needs.build-grafana.outputs.artifact }} + name: grafana-tar-gz - name: Run E2E tests uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e with: @@ -354,7 +419,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@v4 with: - name: ${{ needs.build-grafana.outputs.artifact }} + name: grafana-tar-gz - name: Run PR a11y test if: github.event_name == 'pull_request' uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e diff --git a/.github/workflows/pr-go-workspace-check.yml b/.github/workflows/pr-go-workspace-check.yml index 91dc3037f11..0fa96a1da3f 100644 --- a/.github/workflows/pr-go-workspace-check.yml +++ b/.github/workflows/pr-go-workspace-check.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: Set go version - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: cache: false go-version-file: go.mod @@ -76,7 +76,7 @@ jobs: persist-credentials: false - name: Set go version - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: cache: false go-version-file: go.mod diff --git a/.github/workflows/pr-k8s-codegen-check.yml b/.github/workflows/pr-k8s-codegen-check.yml index 6c34674e5c9..5441b4780b5 100644 --- a/.github/workflows/pr-k8s-codegen-check.yml +++ b/.github/workflows/pr-k8s-codegen-check.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Set go version - uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version-file: go.mod diff --git a/.github/workflows/pr-test-integration.yml b/.github/workflows/pr-test-integration.yml index 76f2068172c..314ca335979 100644 --- a/.github/workflows/pr-test-integration.yml +++ b/.github/workflows/pr-test-integration.yml @@ -37,7 +37,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod cache: true @@ -81,7 +81,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod cache: true @@ -126,7 +126,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/publish-kinds-next.yml b/.github/workflows/publish-kinds-next.yml index 495cb35abae..901d6fc3587 100644 --- a/.github/workflows/publish-kinds-next.yml +++ b/.github/workflows/publish-kinds-next.yml @@ -27,7 +27,7 @@ jobs: persist-credentials: false - name: "Setup Go" - uses: "actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639" + uses: "actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5" with: go-version-file: go.mod diff --git a/.github/workflows/publish-kinds-release.yml b/.github/workflows/publish-kinds-release.yml index 03873c27641..0fd991d66f3 100644 --- a/.github/workflows/publish-kinds-release.yml +++ b/.github/workflows/publish-kinds-release.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: "Setup Go" - uses: "actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639" + uses: "actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5" with: go-version-file: go.mod diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 231bb0395c2..33288a4f971 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -1,6 +1,12 @@ name: Build Release Packages on: workflow_dispatch: + inputs: + source-event: + description: If this workflow was triggered by another workflow, this value should be set to the GITHUB_EVENT_NAME of that source workflow. + type: string + required: false + default: workflow_dispatch schedule: # Every weeknight at midnight # "Scheduled workflows will only run on the default branch." (docs.github.com) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 2c43ae43de4..8018aaca34d 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -136,7 +136,7 @@ jobs: uses: actions/setup-node@v4 with: node-version-file: .nvmrc - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod - name: Configure git user diff --git a/.github/workflows/run-dashboard-search-e2e.yml b/.github/workflows/run-dashboard-search-e2e.yml index 6eebaf9320a..b4d217f25b8 100644 --- a/.github/workflows/run-dashboard-search-e2e.yml +++ b/.github/workflows/run-dashboard-search-e2e.yml @@ -29,7 +29,7 @@ jobs: with: persist-credentials: false - name: Pin Go version to mod file - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: 'go.mod' cache: true @@ -51,7 +51,7 @@ jobs: run: yarn install --immutable - name: Install Cypress dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' - uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f + uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c with: runTests: false - name: Cache Grafana Build and Dependencies diff --git a/.github/workflows/run-schema-v2-e2e.yml b/.github/workflows/run-schema-v2-e2e.yml index a13b953648b..2430da2f905 100644 --- a/.github/workflows/run-schema-v2-e2e.yml +++ b/.github/workflows/run-schema-v2-e2e.yml @@ -22,7 +22,7 @@ jobs: with: persist-credentials: false - name: Pin Go version to mod file - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: 'go.mod' - run: go version @@ -35,7 +35,7 @@ jobs: - name: Build grafana run: make build - name: Install Cypress dependencies - uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f + uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c with: runTests: false - name: Run dashboard scenes e2e diff --git a/.github/workflows/swagger-gen.yml b/.github/workflows/swagger-gen.yml index ee73adc1c28..0e42e0e7441 100644 --- a/.github/workflows/swagger-gen.yml +++ b/.github/workflows/swagger-gen.yml @@ -28,7 +28,7 @@ jobs: with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v5.5.0 with: go-version-file: go.mod - name: Setup Enterprise diff --git a/.github/workflows/verify-kinds.yml b/.github/workflows/verify-kinds.yml index c793dcb2895..1d6e8e80b38 100644 --- a/.github/workflows/verify-kinds.yml +++ b/.github/workflows/verify-kinds.yml @@ -17,7 +17,7 @@ jobs: persist-credentials: false - name: "Setup Go" - uses: "actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639" + uses: "actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5" with: go-version-file: go.mod diff --git a/Makefile b/Makefile index 1d058780a48..11bf0062277 100644 --- a/Makefile +++ b/Makefile @@ -276,7 +276,7 @@ run-frontend: deps-js ## Fetch js dependencies and watch frontend for rebuild yarn start .PHONY: run-bra -run-air: ## [Deprecated] Build and run web server on filesystem changes. See /.bra.toml for configuration. +run-bra: ## [Deprecated] Build and run web server on filesystem changes. See /.bra.toml for configuration. $(bra) run .PHONY: frontend-service-check @@ -483,7 +483,7 @@ protobuf: ## Compile protobuf definitions go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.4.0 buf generate pkg/plugins/backendplugin/pluginextensionv2 --template pkg/plugins/backendplugin/pluginextensionv2/buf.gen.yaml - buf generate apps/secret/decrypt/v1beta1 --template apps/secret/decrypt/v1beta1/buf.gen.yaml + buf generate apps/secret --template apps/secret/buf.gen.yaml buf generate pkg/storage/unified/proto --template pkg/storage/unified/proto/buf.gen.yaml buf generate pkg/services/authz/proto/v1 --template pkg/services/authz/proto/v1/buf.gen.yaml buf generate pkg/services/ngalert/store/proto/v1 --template pkg/services/ngalert/store/proto/v1/buf.gen.yaml diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 4559d1045d9..2a232b42873 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -7,8 +7,8 @@ require ( github.com/google/go-github/v70 v70.0.0 github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.40.1 - github.com/grafana/grafana-app-sdk/logging v0.40.0 + github.com/grafana/grafana-app-sdk v0.40.2 + github.com/grafana/grafana-app-sdk/logging v0.40.1 github.com/grafana/grafana-plugin-sdk-go v0.278.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250725144121-b1592b5e36d2 github.com/stretchr/testify v1.10.0 @@ -79,6 +79,7 @@ require ( github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // indirect github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect github.com/dolthub/vitess v0.0.0-20250410090211-143e6b272ad4 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/elazarl/goproxy v1.7.2 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect @@ -125,7 +126,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b // indirect + github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 // indirect github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 // indirect @@ -190,6 +191,7 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nikunjy/rules v1.5.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect @@ -213,6 +215,7 @@ require ( github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -284,6 +287,10 @@ require ( k8s.io/component-base v0.33.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + modernc.org/libc v1.65.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect + modernc.org/sqlite v1.37.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index b83026226ad..653ca2c8c71 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -651,8 +651,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b h1:mfUAq/N+mS82EcE35hDXWtfVY7UhTjzZxzssvFt9tvQ= -github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= +github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 h1:hcr/AmPB0KL4H+gCEFIdKUnkihTxGAkAOiZA7GDYoL8= +github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 h1:vVPT0i5Y1vI6qzecYStV2yk7cHKrC3Pc7AgvwT5KydQ= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/oUn6Cr90QbJYpQJ4FnjyAIG9Ex5GtTZIzw= @@ -663,22 +663,22 @@ github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6k github.com/grafana/dataplane/sdata v0.0.9/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg2svGyO40Ex92G02aOyHzP6XHCE= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-aws-sdk v1.0.4 h1:D14UAehsOqpjliHmHzveRQ1p43KCsMzdmb7GovWj+SY= github.com/grafana/grafana-aws-sdk v1.0.4/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho= github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441 h1:+TSbaxCXBZrKkdROWBzdWna8uStE1f9LYd7GiqjVfz8= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441/go.mod h1:1XWiRSVuDQiayapHhQiDc4S4e9GzEZgg/3GeNCuDgn4= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017 h1:Niy+KRDWHsUVqfhZQg0oZbAQFO6QcO6a4l9V/ouDEEs= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017/go.mod h1:/iuseD/cEpXDiy7MpL+4qBFZ3H6esnUJTYzpoJMw9dw= github.com/grafana/grafana/apps/folder v0.0.0-20250627191313-2f1a6ae1712b h1:31MwoIKKT9Ay0ZjbT4lkfcPijiWogUWzXs2EjrCgodI= github.com/grafana/grafana/apps/folder v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:dLtYBp1pza5HYalezNvzlP8JDeKrZ5BKTonDgEOE0NY= -github.com/grafana/grafana/apps/secret v0.0.0-20250711114246-c9b2126c4ad5 h1:+fMhUoqwGdY8ntH0GL2icJa3uk+bTiIMicawDG2r9Uc= -github.com/grafana/grafana/apps/secret v0.0.0-20250711114246-c9b2126c4ad5/go.mod h1:TIrKvhgo2j6lvVeOZ3TUmXbI4I48d6v7QcadL/f6SKQ= +github.com/grafana/grafana/apps/secret v0.0.0-20250731151929-0aac22a9e2d3 h1:16eaVEucbwis3TxS4CYZxxg5wfPAP/6u7Ji2+wbiHyk= +github.com/grafana/grafana/apps/secret v0.0.0-20250731151929-0aac22a9e2d3/go.mod h1:pS2M5ILsHx9VNTM96glLtCjCVXHWyfGcT34WHvbbMtM= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250627191313-2f1a6ae1712b h1:ei01IFqmnXkOrrVvsT3CYe+i5xYra3SCX7Wsu3PMsDU= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:+H4Va9jDJlGQJjAN+OFD/hLx2I/yEzDRMQLaKecvgAc= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250725144121-b1592b5e36d2 h1:lvmcK9XOJUJiYhl2kH4nwAKOUdq+ug+ueIGqfKlip3E= @@ -944,6 +944,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nikunjy/rules v1.5.0 h1:KJDSLOsFhwt7kcXUyZqwkgrQg5YoUwj+TVu6ItCQShw= github.com/nikunjy/rules v1.5.0/go.mod h1:TlZtZdBChrkqi8Lr2AXocme8Z7EsbxtFdDoKeI6neBQ= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -1056,6 +1058,8 @@ github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2z github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -1910,6 +1914,30 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 8fd63458f4f..5698787f545 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/alerting/notifications go 1.24.5 require ( - github.com/grafana/grafana-app-sdk v0.40.1 - github.com/grafana/grafana-app-sdk/logging v0.40.0 + github.com/grafana/grafana-app-sdk v0.40.2 + github.com/grafana/grafana-app-sdk/logging v0.40.1 k8s.io/apimachinery v0.33.3 k8s.io/apiserver v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index e3c83758737..2931a8f5200 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -84,10 +84,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 8bbf2e9f607..0d00cb6c511 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -4,7 +4,7 @@ go 1.24.5 require ( cuelang.org/go v0.11.1 - github.com/grafana/grafana-app-sdk v0.40.1 + github.com/grafana/grafana-app-sdk v0.40.2 github.com/grafana/grafana-plugin-sdk-go v0.278.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/stretchr/testify v1.10.0 @@ -44,7 +44,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 4f725783309..bb90120dc2b 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -94,10 +94,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1 github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho= github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go index 50847df87c3..a267e0c8df8 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_object_gen.go @@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{} // Copy methods for all subresource types - - // DeepCopy creates a full deep copy of DashboardStatus func (s *DashboardStatus) DeepCopy() *DashboardStatus { cpy := &DashboardStatus{} diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go index 1423c7b0603..be021b5f003 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_object_gen.go @@ -294,8 +294,6 @@ var _ resource.ListObject = &DashboardList{} // Copy methods for all subresource types - - // DeepCopy creates a full deep copy of DashboardStatus func (s *DashboardStatus) DeepCopy() *DashboardStatus { cpy := &DashboardStatus{} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json index 617b33a8108..7662f66d855 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v2beta1.json @@ -1,6 +1,6 @@ { "kind": "Dashboard", - "apiVersion": "v2beta1", + "apiVersion": "dashboard.grafana.app/v2beta1", "metadata": { "name": "test-v2alpha1-complete", "creationTimestamp": null, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json index a7f9231756a..ba6bc946dc0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json @@ -1,6 +1,6 @@ { "kind": "Dashboard", - "apiVersion": "v2beta1", + "apiVersion": "dashboard.grafana.app/v2beta1", "metadata": { "name": "test-v2alpha1-annotations", "creationTimestamp": null diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json index ff37296efe6..9d34353a32b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.groupby-adhoc-vars.v2beta1.json @@ -1,6 +1,6 @@ { "kind": "Dashboard", - "apiVersion": "v2beta1", + "apiVersion": "dashboard.grafana.app/v2beta1", "metadata": { "name": "test-v2alpha1-groupby-adhoc-vars", "creationTimestamp": null diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json index 0fe38667e6e..b867057085e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v2beta1.json @@ -1,6 +1,6 @@ { "kind": "Dashboard", - "apiVersion": "v2beta1", + "apiVersion": "dashboard.grafana.app/v2beta1", "metadata": { "name": "test-v2alpha1-viz-config", "creationTimestamp": null diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go index 05d7aa0ada0..ec673c80137 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go @@ -39,7 +39,7 @@ import ( func ConvertDashboard_V2alpha1_to_V2beta1(in *dashv2alpha1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope) error { out.ObjectMeta = in.ObjectMeta - out.APIVersion = dashv2beta1.VERSION + out.APIVersion = dashv2beta1.APIVERSION out.Kind = in.Kind return convertDashboardSpec_V2alpha1_to_V2beta1(&in.Spec, &out.Spec, scope) diff --git a/apps/folder/go.mod b/apps/folder/go.mod index 31f8f6078a3..7736f202c1c 100644 --- a/apps/folder/go.mod +++ b/apps/folder/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/folder go 1.24.5 require ( - github.com/grafana/grafana-app-sdk v0.40.1 + github.com/grafana/grafana-app-sdk v0.40.2 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e k8s.io/apimachinery v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff @@ -23,7 +23,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/folder/go.sum b/apps/folder/go.sum index 9707dfb4d4a..3612fc451ee 100644 --- a/apps/folder/go.sum +++ b/apps/folder/go.sum @@ -32,10 +32,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 4b98e9c089f..30df8cdcd1f 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/iam go 1.24.5 require ( - github.com/grafana/grafana-app-sdk v0.40.1 + github.com/grafana/grafana-app-sdk v0.40.2 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e k8s.io/apimachinery v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff @@ -23,7 +23,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 9707dfb4d4a..3612fc451ee 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -32,10 +32,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index 33eb4e290ac..fe28636873c 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -4,7 +4,7 @@ go 1.24.5 require ( github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.40.1 + github.com/grafana/grafana-app-sdk v0.40.2 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250725152715-69d3b9023cec github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.33.3 @@ -49,6 +49,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/elazarl/goproxy v1.7.2 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect @@ -77,12 +78,12 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b // indirect + github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 // indirect github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 // indirect github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect github.com/grafana/grafana-aws-sdk v1.0.4 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // indirect github.com/grafana/grafana-plugin-sdk-go v0.278.0 // indirect @@ -136,6 +137,7 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nikunjy/rules v1.5.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect @@ -157,6 +159,7 @@ require ( github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect @@ -213,6 +216,10 @@ require ( k8s.io/client-go v0.33.3 // indirect k8s.io/component-base v0.33.3 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + modernc.org/libc v1.65.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect + modernc.org/sqlite v1.37.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index 88dfe75aef3..00347e0818c 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -170,6 +170,8 @@ github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 h1:bMGS25NWAGTE github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71/go.mod h1:2/2zjLQ/JOOSbbSboojeg+cAwcRV0fDLzIiWch/lhqI= github.com/dolthub/vitess v0.0.0-20250410090211-143e6b272ad4 h1:LGTt2LtYX8vaai32d+c9L0sMcP+Dg9w1kO6+lbsxxYg= github.com/dolthub/vitess v0.0.0-20250410090211-143e6b272ad4/go.mod h1:1gQZs/byeHLMSul3Lvl3MzioMtOW1je79QYGyi2fd70= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -331,8 +333,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b h1:mfUAq/N+mS82EcE35hDXWtfVY7UhTjzZxzssvFt9tvQ= -github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= +github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 h1:hcr/AmPB0KL4H+gCEFIdKUnkihTxGAkAOiZA7GDYoL8= +github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 h1:vVPT0i5Y1vI6qzecYStV2yk7cHKrC3Pc7AgvwT5KydQ= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/oUn6Cr90QbJYpQJ4FnjyAIG9Ex5GtTZIzw= @@ -341,18 +343,18 @@ github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6k github.com/grafana/dataplane/sdata v0.0.9/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg2svGyO40Ex92G02aOyHzP6XHCE= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-aws-sdk v1.0.4 h1:D14UAehsOqpjliHmHzveRQ1p43KCsMzdmb7GovWj+SY= github.com/grafana/grafana-aws-sdk v1.0.4/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho= github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441 h1:+TSbaxCXBZrKkdROWBzdWna8uStE1f9LYd7GiqjVfz8= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441/go.mod h1:1XWiRSVuDQiayapHhQiDc4S4e9GzEZgg/3GeNCuDgn4= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017 h1:Niy+KRDWHsUVqfhZQg0oZbAQFO6QcO6a4l9V/ouDEEs= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017/go.mod h1:/iuseD/cEpXDiy7MpL+4qBFZ3H6esnUJTYzpoJMw9dw= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250725152715-69d3b9023cec h1:cg1GbDVZ7goqDrqoMzqeN4AeAcD271MGYjOvdVTDwfw= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250725152715-69d3b9023cec/go.mod h1:3ZgUe0E3rIhI026xF4DKFptOst/jpDHJ/Sn+bRODzI4= github.com/grafana/grafana/pkg/apiserver v0.0.0-20250627191313-2f1a6ae1712b h1:QyJLJn3xwFTIXu9KPZujsrIUN0X8DdiR9b2h75L0AfI= @@ -540,6 +542,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nikunjy/rules v1.5.0 h1:KJDSLOsFhwt7kcXUyZqwkgrQg5YoUwj+TVu6ItCQShw= github.com/nikunjy/rules v1.5.0/go.mod h1:TlZtZdBChrkqi8Lr2AXocme8Z7EsbxtFdDoKeI6neBQ= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= @@ -626,6 +630,8 @@ github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlT github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -994,6 +1000,30 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index 2cf20613fc8..6a3f8d519fd 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/playlist go 1.24.5 require ( - github.com/grafana/grafana-app-sdk v0.40.1 + github.com/grafana/grafana-app-sdk v0.40.2 k8s.io/apimachinery v0.33.3 k8s.io/client-go v0.33.3 k8s.io/klog/v2 v2.130.1 @@ -30,7 +30,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index b061911f7b2..88512ac2ed8 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -47,10 +47,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/apps/sdk.mk b/apps/sdk.mk index 093075f0be4..07105fe3973 100644 --- a/apps/sdk.mk +++ b/apps/sdk.mk @@ -1,4 +1,4 @@ -APP_SDK_VERSION = v0.40.1 +APP_SDK_VERSION = v0.40.2 APP_SDK_DIR = $(shell go env GOPATH)/bin/app-sdk-$(APP_SDK_VERSION) APP_SDK_BIN = $(APP_SDK_DIR)/grafana-app-sdk diff --git a/apps/secret/decrypt/v1beta1/buf.gen.yaml b/apps/secret/buf.gen.yaml similarity index 70% rename from apps/secret/decrypt/v1beta1/buf.gen.yaml rename to apps/secret/buf.gen.yaml index b2691bae669..6c5c0ee07fe 100644 --- a/apps/secret/decrypt/v1beta1/buf.gen.yaml +++ b/apps/secret/buf.gen.yaml @@ -2,11 +2,11 @@ version: v1 plugins: - plugin: go - out: apps/secret/decrypt/v1beta1 + out: apps/secret opt: - paths=source_relative - plugin: go-grpc - out: apps/secret/decrypt/v1beta1 + out: apps/secret opt: - paths=source_relative - require_unimplemented_servers=false diff --git a/apps/secret/decrypt/v1beta1/buf.yaml b/apps/secret/buf.yaml similarity index 100% rename from apps/secret/decrypt/v1beta1/buf.yaml rename to apps/secret/buf.yaml diff --git a/apps/secret/consolidate/v1beta1/consolidate.pb.go b/apps/secret/consolidate/v1beta1/consolidate.pb.go new file mode 100644 index 00000000000..c6ab78f22a7 --- /dev/null +++ b/apps/secret/consolidate/v1beta1/consolidate.pb.go @@ -0,0 +1,155 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc (unknown) +// source: consolidate/v1beta1/consolidate.proto + +package consolidatev1beta1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SecretsConsolidateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // true if successful, false if error + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // success or error message + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecretsConsolidateResponse) Reset() { + *x = SecretsConsolidateResponse{} + mi := &file_consolidate_v1beta1_consolidate_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecretsConsolidateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsConsolidateResponse) ProtoMessage() {} + +func (x *SecretsConsolidateResponse) ProtoReflect() protoreflect.Message { + mi := &file_consolidate_v1beta1_consolidate_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsConsolidateResponse.ProtoReflect.Descriptor instead. +func (*SecretsConsolidateResponse) Descriptor() ([]byte, []int) { + return file_consolidate_v1beta1_consolidate_proto_rawDescGZIP(), []int{0} +} + +func (x *SecretsConsolidateResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *SecretsConsolidateResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_consolidate_v1beta1_consolidate_proto protoreflect.FileDescriptor + +var file_consolidate_v1beta1_consolidate_proto_rawDesc = string([]byte{ + 0x0a, 0x25, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x31, + 0x62, 0x65, 0x74, 0x61, 0x31, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x12, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, + 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x50, 0x0a, 0x1a, 0x53, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x6c, 0x0a, 0x13, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x6f, + 0x72, 0x12, 0x55, 0x0a, 0x0b, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, + 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, + 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x2e, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x4f, 0x5a, 0x4d, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, + 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x73, 0x2f, 0x73, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, + 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x3b, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x65, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +}) + +var ( + file_consolidate_v1beta1_consolidate_proto_rawDescOnce sync.Once + file_consolidate_v1beta1_consolidate_proto_rawDescData []byte +) + +func file_consolidate_v1beta1_consolidate_proto_rawDescGZIP() []byte { + file_consolidate_v1beta1_consolidate_proto_rawDescOnce.Do(func() { + file_consolidate_v1beta1_consolidate_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_consolidate_v1beta1_consolidate_proto_rawDesc), len(file_consolidate_v1beta1_consolidate_proto_rawDesc))) + }) + return file_consolidate_v1beta1_consolidate_proto_rawDescData +} + +var file_consolidate_v1beta1_consolidate_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_consolidate_v1beta1_consolidate_proto_goTypes = []any{ + (*SecretsConsolidateResponse)(nil), // 0: consolidatev1beta1.SecretsConsolidateResponse + (*emptypb.Empty)(nil), // 1: google.protobuf.Empty +} +var file_consolidate_v1beta1_consolidate_proto_depIdxs = []int32{ + 1, // 0: consolidatev1beta1.SecretsConsolidator.Consolidate:input_type -> google.protobuf.Empty + 0, // 1: consolidatev1beta1.SecretsConsolidator.Consolidate:output_type -> consolidatev1beta1.SecretsConsolidateResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_consolidate_v1beta1_consolidate_proto_init() } +func file_consolidate_v1beta1_consolidate_proto_init() { + if File_consolidate_v1beta1_consolidate_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_consolidate_v1beta1_consolidate_proto_rawDesc), len(file_consolidate_v1beta1_consolidate_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_consolidate_v1beta1_consolidate_proto_goTypes, + DependencyIndexes: file_consolidate_v1beta1_consolidate_proto_depIdxs, + MessageInfos: file_consolidate_v1beta1_consolidate_proto_msgTypes, + }.Build() + File_consolidate_v1beta1_consolidate_proto = out.File + file_consolidate_v1beta1_consolidate_proto_goTypes = nil + file_consolidate_v1beta1_consolidate_proto_depIdxs = nil +} diff --git a/apps/secret/consolidate/v1beta1/consolidate.proto b/apps/secret/consolidate/v1beta1/consolidate.proto new file mode 100644 index 00000000000..56bd4b7d898 --- /dev/null +++ b/apps/secret/consolidate/v1beta1/consolidate.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package consolidatev1beta1; + +option go_package = "github.com/grafana/grafana/apps/secret/consolidate/v1beta1;consolidatev1beta1"; + +import "google/protobuf/empty.proto"; + +message SecretsConsolidateResponse { + bool success = 1; // true if successful, false if error + string message = 2; // success or error message +} + +service SecretsConsolidator { + // Consolidates secrets and returns success or error message. + rpc Consolidate(google.protobuf.Empty) returns (SecretsConsolidateResponse); +} diff --git a/apps/secret/consolidate/v1beta1/consolidate_grpc.pb.go b/apps/secret/consolidate/v1beta1/consolidate_grpc.pb.go new file mode 100644 index 00000000000..510f1ac8449 --- /dev/null +++ b/apps/secret/consolidate/v1beta1/consolidate_grpc.pb.go @@ -0,0 +1,111 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.4.0 +// - protoc (unknown) +// source: consolidate/v1beta1/consolidate.proto + +package consolidatev1beta1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.62.0 or later. +const _ = grpc.SupportPackageIsVersion8 + +const ( + SecretsConsolidator_Consolidate_FullMethodName = "/consolidatev1beta1.SecretsConsolidator/Consolidate" +) + +// SecretsConsolidatorClient is the client API for SecretsConsolidator service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type SecretsConsolidatorClient interface { + // Consolidates secrets and returns success or error message. + Consolidate(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecretsConsolidateResponse, error) +} + +type secretsConsolidatorClient struct { + cc grpc.ClientConnInterface +} + +func NewSecretsConsolidatorClient(cc grpc.ClientConnInterface) SecretsConsolidatorClient { + return &secretsConsolidatorClient{cc} +} + +func (c *secretsConsolidatorClient) Consolidate(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecretsConsolidateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SecretsConsolidateResponse) + err := c.cc.Invoke(ctx, SecretsConsolidator_Consolidate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SecretsConsolidatorServer is the server API for SecretsConsolidator service. +// All implementations should embed UnimplementedSecretsConsolidatorServer +// for forward compatibility +type SecretsConsolidatorServer interface { + // Consolidates secrets and returns success or error message. + Consolidate(context.Context, *emptypb.Empty) (*SecretsConsolidateResponse, error) +} + +// UnimplementedSecretsConsolidatorServer should be embedded to have forward compatible implementations. +type UnimplementedSecretsConsolidatorServer struct { +} + +func (UnimplementedSecretsConsolidatorServer) Consolidate(context.Context, *emptypb.Empty) (*SecretsConsolidateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Consolidate not implemented") +} + +// UnsafeSecretsConsolidatorServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SecretsConsolidatorServer will +// result in compilation errors. +type UnsafeSecretsConsolidatorServer interface { + mustEmbedUnimplementedSecretsConsolidatorServer() +} + +func RegisterSecretsConsolidatorServer(s grpc.ServiceRegistrar, srv SecretsConsolidatorServer) { + s.RegisterService(&SecretsConsolidator_ServiceDesc, srv) +} + +func _SecretsConsolidator_Consolidate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SecretsConsolidatorServer).Consolidate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SecretsConsolidator_Consolidate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SecretsConsolidatorServer).Consolidate(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +// SecretsConsolidator_ServiceDesc is the grpc.ServiceDesc for SecretsConsolidator service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SecretsConsolidator_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "consolidatev1beta1.SecretsConsolidator", + HandlerType: (*SecretsConsolidatorServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Consolidate", + Handler: _SecretsConsolidator_Consolidate_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "consolidate/v1beta1/consolidate.proto", +} diff --git a/apps/secret/decrypt/v1beta1/decrypt.pb.go b/apps/secret/decrypt/v1beta1/decrypt.pb.go index 4e334ff7ca3..8ed82d362ce 100644 --- a/apps/secret/decrypt/v1beta1/decrypt.pb.go +++ b/apps/secret/decrypt/v1beta1/decrypt.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.5 // protoc (unknown) -// source: decrypt.proto +// source: decrypt/v1beta1/decrypt.proto package decryptv1beta1 @@ -33,7 +33,7 @@ type SecureValueDecryptRequest struct { func (x *SecureValueDecryptRequest) Reset() { *x = SecureValueDecryptRequest{} - mi := &file_decrypt_proto_msgTypes[0] + mi := &file_decrypt_v1beta1_decrypt_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -45,7 +45,7 @@ func (x *SecureValueDecryptRequest) String() string { func (*SecureValueDecryptRequest) ProtoMessage() {} func (x *SecureValueDecryptRequest) ProtoReflect() protoreflect.Message { - mi := &file_decrypt_proto_msgTypes[0] + mi := &file_decrypt_v1beta1_decrypt_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -58,7 +58,7 @@ func (x *SecureValueDecryptRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SecureValueDecryptRequest.ProtoReflect.Descriptor instead. func (*SecureValueDecryptRequest) Descriptor() ([]byte, []int) { - return file_decrypt_proto_rawDescGZIP(), []int{0} + return file_decrypt_v1beta1_decrypt_proto_rawDescGZIP(), []int{0} } func (x *SecureValueDecryptRequest) GetNamespace() string { @@ -87,7 +87,7 @@ type SecureValueDecryptResponseCollection struct { func (x *SecureValueDecryptResponseCollection) Reset() { *x = SecureValueDecryptResponseCollection{} - mi := &file_decrypt_proto_msgTypes[1] + mi := &file_decrypt_v1beta1_decrypt_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -99,7 +99,7 @@ func (x *SecureValueDecryptResponseCollection) String() string { func (*SecureValueDecryptResponseCollection) ProtoMessage() {} func (x *SecureValueDecryptResponseCollection) ProtoReflect() protoreflect.Message { - mi := &file_decrypt_proto_msgTypes[1] + mi := &file_decrypt_v1beta1_decrypt_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -112,7 +112,7 @@ func (x *SecureValueDecryptResponseCollection) ProtoReflect() protoreflect.Messa // Deprecated: Use SecureValueDecryptResponseCollection.ProtoReflect.Descriptor instead. func (*SecureValueDecryptResponseCollection) Descriptor() ([]byte, []int) { - return file_decrypt_proto_rawDescGZIP(), []int{1} + return file_decrypt_v1beta1_decrypt_proto_rawDescGZIP(), []int{1} } func (x *SecureValueDecryptResponseCollection) GetDecryptedValues() map[string]*Result { @@ -135,7 +135,7 @@ type Result struct { func (x *Result) Reset() { *x = Result{} - mi := &file_decrypt_proto_msgTypes[2] + mi := &file_decrypt_v1beta1_decrypt_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -147,7 +147,7 @@ func (x *Result) String() string { func (*Result) ProtoMessage() {} func (x *Result) ProtoReflect() protoreflect.Message { - mi := &file_decrypt_proto_msgTypes[2] + mi := &file_decrypt_v1beta1_decrypt_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -160,7 +160,7 @@ func (x *Result) ProtoReflect() protoreflect.Message { // Deprecated: Use Result.ProtoReflect.Descriptor instead. func (*Result) Descriptor() ([]byte, []int) { - return file_decrypt_proto_rawDescGZIP(), []int{2} + return file_decrypt_v1beta1_decrypt_proto_rawDescGZIP(), []int{2} } func (x *Result) GetResult() isResult_Result { @@ -204,10 +204,11 @@ func (*Result_Value) isResult_Result() {} func (*Result_ErrorMessage) isResult_Result() {} -var File_decrypt_proto protoreflect.FileDescriptor +var File_decrypt_v1beta1_decrypt_proto protoreflect.FileDescriptor -var file_decrypt_proto_rawDesc = string([]byte{ - 0x0a, 0x0d, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, +var file_decrypt_v1beta1_decrypt_proto_rawDesc = string([]byte{ + 0x0a, 0x1d, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, + 0x31, 0x2f, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x76, 0x31, 0x62, 0x65, 0x74, 0x61, 0x31, 0x22, 0x4f, 0x0a, 0x19, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, @@ -252,25 +253,25 @@ var file_decrypt_proto_rawDesc = string([]byte{ }) var ( - file_decrypt_proto_rawDescOnce sync.Once - file_decrypt_proto_rawDescData []byte + file_decrypt_v1beta1_decrypt_proto_rawDescOnce sync.Once + file_decrypt_v1beta1_decrypt_proto_rawDescData []byte ) -func file_decrypt_proto_rawDescGZIP() []byte { - file_decrypt_proto_rawDescOnce.Do(func() { - file_decrypt_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_decrypt_proto_rawDesc), len(file_decrypt_proto_rawDesc))) +func file_decrypt_v1beta1_decrypt_proto_rawDescGZIP() []byte { + file_decrypt_v1beta1_decrypt_proto_rawDescOnce.Do(func() { + file_decrypt_v1beta1_decrypt_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_decrypt_v1beta1_decrypt_proto_rawDesc), len(file_decrypt_v1beta1_decrypt_proto_rawDesc))) }) - return file_decrypt_proto_rawDescData + return file_decrypt_v1beta1_decrypt_proto_rawDescData } -var file_decrypt_proto_msgTypes = make([]protoimpl.MessageInfo, 4) -var file_decrypt_proto_goTypes = []any{ +var file_decrypt_v1beta1_decrypt_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_decrypt_v1beta1_decrypt_proto_goTypes = []any{ (*SecureValueDecryptRequest)(nil), // 0: decryptv1beta1.SecureValueDecryptRequest (*SecureValueDecryptResponseCollection)(nil), // 1: decryptv1beta1.SecureValueDecryptResponseCollection (*Result)(nil), // 2: decryptv1beta1.Result nil, // 3: decryptv1beta1.SecureValueDecryptResponseCollection.DecryptedValuesEntry } -var file_decrypt_proto_depIdxs = []int32{ +var file_decrypt_v1beta1_decrypt_proto_depIdxs = []int32{ 3, // 0: decryptv1beta1.SecureValueDecryptResponseCollection.decrypted_values:type_name -> decryptv1beta1.SecureValueDecryptResponseCollection.DecryptedValuesEntry 2, // 1: decryptv1beta1.SecureValueDecryptResponseCollection.DecryptedValuesEntry.value:type_name -> decryptv1beta1.Result 0, // 2: decryptv1beta1.SecureValueDecrypter.DecryptSecureValues:input_type -> decryptv1beta1.SecureValueDecryptRequest @@ -282,12 +283,12 @@ var file_decrypt_proto_depIdxs = []int32{ 0, // [0:2] is the sub-list for field type_name } -func init() { file_decrypt_proto_init() } -func file_decrypt_proto_init() { - if File_decrypt_proto != nil { +func init() { file_decrypt_v1beta1_decrypt_proto_init() } +func file_decrypt_v1beta1_decrypt_proto_init() { + if File_decrypt_v1beta1_decrypt_proto != nil { return } - file_decrypt_proto_msgTypes[2].OneofWrappers = []any{ + file_decrypt_v1beta1_decrypt_proto_msgTypes[2].OneofWrappers = []any{ (*Result_Value)(nil), (*Result_ErrorMessage)(nil), } @@ -295,17 +296,17 @@ func file_decrypt_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_decrypt_proto_rawDesc), len(file_decrypt_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_decrypt_v1beta1_decrypt_proto_rawDesc), len(file_decrypt_v1beta1_decrypt_proto_rawDesc)), NumEnums: 0, NumMessages: 4, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_decrypt_proto_goTypes, - DependencyIndexes: file_decrypt_proto_depIdxs, - MessageInfos: file_decrypt_proto_msgTypes, + GoTypes: file_decrypt_v1beta1_decrypt_proto_goTypes, + DependencyIndexes: file_decrypt_v1beta1_decrypt_proto_depIdxs, + MessageInfos: file_decrypt_v1beta1_decrypt_proto_msgTypes, }.Build() - File_decrypt_proto = out.File - file_decrypt_proto_goTypes = nil - file_decrypt_proto_depIdxs = nil + File_decrypt_v1beta1_decrypt_proto = out.File + file_decrypt_v1beta1_decrypt_proto_goTypes = nil + file_decrypt_v1beta1_decrypt_proto_depIdxs = nil } diff --git a/apps/secret/decrypt/v1beta1/decrypt_grpc.pb.go b/apps/secret/decrypt/v1beta1/decrypt_grpc.pb.go index 92092215b58..fc96a64a8b2 100644 --- a/apps/secret/decrypt/v1beta1/decrypt_grpc.pb.go +++ b/apps/secret/decrypt/v1beta1/decrypt_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.4.0 // - protoc (unknown) -// source: decrypt.proto +// source: decrypt/v1beta1/decrypt.proto package decryptv1beta1 @@ -106,5 +106,5 @@ var SecureValueDecrypter_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "decrypt.proto", + Metadata: "decrypt/v1beta1/decrypt.proto", } diff --git a/apps/secret/go.mod b/apps/secret/go.mod index 7f0517a504a..45567c3943a 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -3,7 +3,7 @@ module github.com/grafana/grafana/apps/secret go 1.24.5 require ( - github.com/grafana/grafana-app-sdk v0.40.1 + github.com/grafana/grafana-app-sdk v0.40.2 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf github.com/stretchr/testify v1.10.0 google.golang.org/grpc v1.74.2 @@ -28,7 +28,7 @@ require ( github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/secret/go.sum b/apps/secret/go.sum index 78d210e14a9..3705f402c16 100644 --- a/apps/secret/go.sum +++ b/apps/secret/go.sum @@ -36,10 +36,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf h1:BBGDHffvVNLoYQlXEpbXcxE0vbpq7pm/8OWF5I+UDZg= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf/go.mod h1:eAlOam2uWhrsEZlOoAr7XZ9hbBP7SyYGYn31/aQAPs8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/devenv/frontend-service/.gitignore b/devenv/frontend-service/.gitignore new file mode 100644 index 00000000000..c795b054e5a --- /dev/null +++ b/devenv/frontend-service/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/devenv/frontend-service/README.md b/devenv/frontend-service/README.md index 60acc1e134e..97725978e1a 100644 --- a/devenv/frontend-service/README.md +++ b/devenv/frontend-service/README.md @@ -25,4 +25,4 @@ To simulate the `/bootdata` endpoint being available, there are special control - `/-/down/:seconds` - Simulates the endpoint being unavailable for a custom number of seconds. - `/-/up` - Restores the endpoint to being available. -When unavailable, the API will return `HTTP 503 Service Unavailable` with a JSON payload. \ No newline at end of file +When unavailable, the API will return `HTTP 503 Service Unavailable` with a JSON payload. diff --git a/devenv/frontend-service/Tiltfile b/devenv/frontend-service/Tiltfile index 36709821fa7..f4d3887df3b 100644 --- a/devenv/frontend-service/Tiltfile +++ b/devenv/frontend-service/Tiltfile @@ -1,3 +1,8 @@ +config.define_bool("use-zig") +cfg = config.parse() + +use_zig = cfg.get('docker-builder', False) + # --- Frontend processes local_resource( 'yarn install', @@ -5,7 +10,7 @@ local_resource( deps=[ 'yarn.lock', ], - labels=["frontend"] + labels=["local"] ) local_resource( @@ -13,63 +18,94 @@ local_resource( cmd='rm -rf public/build/assets-manifest.json', serve_cmd='yarn start:noLint', resource_deps=['yarn install'], + + # Note: this doesn't seem to work as expected - the assets-manifest is somehow created before + # the webpack build is complete? readiness_probe=probe( - initial_delay_secs=5, # wait for the assets-manifest.json to first be deleted + initial_delay_secs=10, period_secs=1, - exec=exec_action(["bash", "-c", "cat public/build/assets-manifest.json | grep entrypoints"]) + exec=exec_action(["bash", "-c", "stat public/build/assets-manifest.json"]), ), allow_parallel=True, - labels=["frontend"] + labels=["local"] +) + +build_backend_env = {} +if use_zig: + build_backend_env['USE_ZIG'] = "true" + +local_resource( + 'backend-build', + "bash ./build-grafana.sh", + deps=[ + '../../pkg', + '../../apps', + '../../kinds', + '../../kindsv2', + '../../local', + '../../scripts', + '../../conf', + '../../go.sum', + '../../go.mod', + ], + env=build_backend_env, + allow_parallel=True, + labels=["local"] ) # --- Docker Compose docker_compose("./docker-compose.yaml") +dc_resource("proxy", + resource_deps=["backend", "frontend-service"], + labels=["services"] +) +dc_resource("backend", + resource_deps=["yarn start", "backend-build"], + labels=["services"] +) +dc_resource("frontend-service", + resource_deps=["yarn start", "backend-build"], + labels=["services"], +) -# First argument is the name of the service from the docker-compose file. -dc_resource("backend", resource_deps=["yarn start"], labels=["backend"]) -dc_resource("frontend-service", resource_deps=["yarn start"], labels=["backend"]) -dc_resource("proxy", resource_deps=["backend", "frontend-service"], labels=["ingress"]) +# paths in tilt files are confusing.... +# - if tilt is dealing the the path, it is relative to the Tiltfile +# - if docker is dealing with the path, it is relative to the context +docker_build('grafana-fs-dev', + # Set the docker context to the root of the repo + context='../..', + dockerfile='grafana-fs-dev.dockerfile', -docker_build('grafana-backend', '../..', - dockerfile='backend.dockerfile', - - # Only these paths will be in the docker context, and will trigger a rebuild. - # This must include all the files that are COPY'd in backend.dockerfile + # Paths relative to the docker context (root of the repo) only=[ - "./Makefile", - "./devenv/frontend-service/build-grafana.sh", - - "./apps", - "./pkg", - "./scripts", - - "./go.sum", - "./go.mod", - "./go.work", - "./go.work.sum", - - "./kinds", - "./kindsv2", - "./public/api-merged.json", - "./package.json", - - "./conf/defaults.ini", - "./conf/ldap.toml", - "./conf/ldap_multiple.toml", - "./public/emails", - "./public/views", - "./public/dashboards", - "./public/build/assets-manifest.json", + 'devenv/frontend-service/build/grafana', + 'conf/defaults.ini', + 'public/emails', + 'public/views', + 'public/dashboards', + 'public/app/plugins', + 'public/build/assets-manifest.json', ], + + # Sync paths are relative to the Tiltfile live_update = [ + sync('./build/grafana', '/grafana/bin/grafana'), + sync('../../conf/defaults.ini', '/grafana/conf/defaults.ini'), + sync('../../public/emails', '/grafana/public/emails'), + sync('../../public/views', '/grafana/public/views'), + sync('../../public/dashboards', '/grafana/public/dashboards'), + sync('../../public/app/plugins', '/grafana/public/app/plugins'), sync('../../public/build/assets-manifest.json', '/grafana/public/build/assets-manifest.json'), restart_container() ] ) - -docker_build('grafana-proxy', '.', +docker_build('grafana-proxy', + # Set the docker context to this frontend-service folder + context='.', dockerfile='proxy.dockerfile', + + # Path relative to the docker context (this folder) only=[ "./nginx.conf", ], diff --git a/devenv/frontend-service/backend.dockerfile b/devenv/frontend-service/backend.dockerfile deleted file mode 100644 index 7326802d3a6..00000000000 --- a/devenv/frontend-service/backend.dockerfile +++ /dev/null @@ -1,92 +0,0 @@ -ARG BASE_IMAGE=alpine:3.21 -ARG GO_IMAGE=golang:1.24.5-alpine - -# ----- Go build stage -FROM ${GO_IMAGE} AS go-dev-builder - -RUN apk add --no-cache \ - binutils-gold \ - bash \ - gcc g++ make git jq findutils - -WORKDIR /build-grafana - -RUN go env GOCACHE -RUN go env GOPATH - -# All files COPY'd here must be included in the `only` list in Tiltfile -# otherwise the image will not build with Tilt. - -COPY Makefile devenv/frontend-service/build-grafana.sh ./ - -# Copy go mod files first -# run this command and replace the output below: -# find pkg scripts apps -type f \( -name go.mod -o -name go.sum \) -print | sed -E 's#(.*)/go\.(mod|sum)$#COPY \1/go.* \1/#' | sort -u -COPY apps/advisor/go.* apps/advisor/ -COPY apps/alerting/notifications/go.* apps/alerting/notifications/ -COPY apps/dashboard/go.* apps/dashboard/ -COPY apps/folder/go.* apps/folder/ -COPY apps/iam/go.* apps/iam/ -COPY apps/investigations/go.* apps/investigations/ -COPY apps/playlist/go.* apps/playlist/ -COPY apps/secret/go.* apps/secret/ -COPY pkg/aggregator/go.* pkg/aggregator/ -COPY pkg/apimachinery/go.* pkg/apimachinery/ -COPY pkg/apiserver/go.* pkg/apiserver/ -COPY pkg/build/go.* pkg/build/ -COPY pkg/build/wire/go.* pkg/build/wire/ -COPY pkg/codegen/go.* pkg/codegen/ -COPY pkg/plugins/codegen/go.* pkg/plugins/codegen/ -COPY pkg/promlib/go.* pkg/promlib/ -COPY pkg/semconv/go.* pkg/semconv/ -COPY scripts/go-workspace/go.* scripts/go-workspace/ -COPY scripts/modowners/go.* scripts/modowners/ - -COPY go.* ./ - -# Install dependencies -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - go mod download - -# Copy source files -COPY kinds kinds -COPY kindsv2 kindsv2 -COPY public/api-merged.json public/api-merged.json -COPY apps apps -COPY pkg pkg -COPY package.json package.json - -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - bash build-grafana.sh - - -# ----- Runtime stage -FROM ${BASE_IMAGE} -RUN apk add --no-cache ca-certificates tzdata musl-utils bash - -EXPOSE 3000 - -WORKDIR /grafana - -RUN mkdir -p "conf/provisioning/datasources" \ -"conf/provisioning/dashboards" \ -"conf/provisioning/notifiers" \ -"conf/provisioning/plugins" \ -"conf/provisioning/access-control" \ -"conf/provisioning/alerting" - -# Copy config files -COPY conf/defaults.ini conf/ldap.toml conf/ldap_multiple.toml conf/ - -COPY public/emails public/emails -COPY public/views public/views -COPY public/dashboards public/dashboards - -# Copy the Go binary from the go-dev-builder stage -COPY --from=go-dev-builder /build-grafana/bin/grafana /grafana/bin/grafana - -COPY public/build/assets-manifest.json public/build/assets-manifest.json - -ENTRYPOINT ["bin/grafana", "server"] diff --git a/devenv/frontend-service/build-grafana.sh b/devenv/frontend-service/build-grafana.sh old mode 100644 new mode 100755 index b3adbe16e1a..62bac124d52 --- a/devenv/frontend-service/build-grafana.sh +++ b/devenv/frontend-service/build-grafana.sh @@ -1,12 +1,31 @@ #!/bin/bash +cd ../../ + echo "Go mod cache: $(go env GOMODCACHE), $(ls -1 $(go env GOMODCACHE) | wc -l) items" echo "Go build cache: $(go env GOCACHE), $(ls -1 $(go env GOCACHE) | wc -l) items" +# Set cross-compilation env vars only on macOS (Darwin) +if [[ "$(uname)" == "Darwin" ]]; then + echo "Setting up cross-compilation environment for macOS" + export CGO_ENABLED=0 + export GOOS=linux + export GOARCH=arm64 +fi + +# It's not used by default now that we have CGO-less builds, but keeping this here for a +# little bit in case it causes issues for anyone. +if [[ -n "$USE_ZIG" ]]; then + echo "Using Zig for cross-compilation" + export CGO_ENABLED=1 + export CC="zig cc -target aarch64-linux" + export CXX="zig c++ -target aarch64-linux" +fi + # Need to build version into the binary so plugin compatibility works correctly VERSION=$(jq -r .version package.json) go build -v \ -ldflags "-X main.version=${VERSION}" \ -gcflags "all=-N -l" \ - -o ./bin/grafana ./pkg/cmd/grafana + -o ./devenv/frontend-service/build/grafana ./pkg/cmd/grafana \ No newline at end of file diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index 0da4a780970..1d5c46ba546 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -14,24 +14,29 @@ services: - '3010:81' # CDN backend: - image: grafana-backend + image: grafana-fs-dev build: - context: . - dockerfile: backend.dockerfile + context: ../.. + dockerfile: devenv/frontend-service/grafana-fs-dev.dockerfile entrypoint: ['bin/grafana', 'server'] volumes: - backend-data:/grafana/data - - ../../public/app/plugins:/grafana/public/app/plugins environment: - GF_FEATURE_TOGGLES_ENABLE: multiTenantFrontend GF_SERVER_CDN_URL: http://localhost:3010 + GF_FEATURE_TOGGLES_ENABLE: multiTenantFrontend + GF_DATABASE_TYPE: postgres + GF_DATABASE_HOST: postgres + GF_DATABASE_NAME: grafana + GF_DATABASE_USER: grafana + GF_DATABASE_PASSWORD: grafana ports: - '3011:3000' frontend-service: - image: grafana-backend + image: grafana-fs-dev build: - dockerfile: backend.dockerfile + context: ../.. + dockerfile: devenv/frontend-service/grafana-fs-dev.dockerfile entrypoint: ['bin/grafana', 'server', 'target'] ports: - '3012:3000' @@ -41,5 +46,15 @@ services: GF_SECURITY_CONTENT_SECURITY_POLICY: false GF_SERVER_CDN_URL: http://localhost:3010 + postgres: + image: postgres:16.1-alpine3.19 + environment: + POSTGRES_USER: grafana + POSTGRES_PASSWORD: grafana + POSTGRES_DB: grafana + volumes: + - postgres-data:/var/lib/postgresql/data + volumes: backend-data: + postgres-data: diff --git a/devenv/frontend-service/grafana-fs-dev.dockerfile b/devenv/frontend-service/grafana-fs-dev.dockerfile new file mode 100644 index 00000000000..61f5389ffb9 --- /dev/null +++ b/devenv/frontend-service/grafana-fs-dev.dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:24.04 + +WORKDIR /grafana + +RUN mkdir -p "conf/provisioning/datasources" \ +"conf/provisioning/dashboards" \ +"conf/provisioning/notifiers" \ +"conf/provisioning/plugins" \ +"conf/provisioning/access-control" \ +"conf/provisioning/alerting" + +COPY conf/defaults.ini conf/defaults.ini + +COPY public/emails public/emails +COPY public/views public/views +COPY public/dashboards public/dashboards +COPY public/app/plugins public/app/plugins + +ADD devenv/frontend-service/build/grafana bin/grafana + +COPY public/build/assets-manifest.json public/build/assets-manifest.json + +ENTRYPOINT ["bin/grafana", "server"] \ No newline at end of file diff --git a/devenv/frontend-service/local-init.sh b/devenv/frontend-service/local-init.sh index 4271c0e0dd0..9c393e10715 100755 --- a/devenv/frontend-service/local-init.sh +++ b/devenv/frontend-service/local-init.sh @@ -19,7 +19,6 @@ if ! tilt version &> /dev/null; then IS_OKAY=false fi - if [ "$IS_OKAY" = false ]; then echo "Please fix the above errors before continuing" exit 1 diff --git a/devenv/frontend-service/nginx.conf b/devenv/frontend-service/nginx.conf index 26d6c1f9968..1cf21295a36 100644 --- a/devenv/frontend-service/nginx.conf +++ b/devenv/frontend-service/nginx.conf @@ -60,7 +60,7 @@ server { # API calls go to the backend # Cheat with app plugin paths and route them to the backend. These should come from # the Plugin CDN - location ~ ^/(api|apis|bootdata|logout|public\/plugins\/grafana\-\w+\-app) { + location ~ ^/(api|apis|avatar|bootdata|render|logout|public\/plugins) { if ($cookie_fs_unavailable) { add_header Content-Type application/json always; return 503 '{"code":"Loading", "message": "Soon!"}'; diff --git a/docs/sources/administration/migration-guide/manually-migrate-to-grafana-cloud.md b/docs/sources/administration/migration-guide/manually-migrate-to-grafana-cloud.md index e68083eb4a0..5f4fa75a787 100644 --- a/docs/sources/administration/migration-guide/manually-migrate-to-grafana-cloud.md +++ b/docs/sources/administration/migration-guide/manually-migrate-to-grafana-cloud.md @@ -290,7 +290,7 @@ The following customizations are available via support: - Enabling [feature toggles](http://www.grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/feature-toggles). - [Single sign-on and team sync using SAML, LDAP, or OAuth](http://www.grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication). - Enable [embedding Grafana dashboards in other applications](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#allow_embedding) for Grafana Cloud contracted customers. -- [Audit logging](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/audit-grafana/) ([Usage insights logs and dashboards](https://grafana.com/docs/grafana-cloud/account-management/usage-insights/) are available in Grafana Cloud Pro and Advanced by default). +- [Audit logging](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/audit-grafana/) ([Usage insights logs and dashboards](https://grafana.com/docs/grafana-cloud/account-management/usage-insights/) are available in select Grafana Cloud paid accounts). Note that the following custom configurations are not supported in Grafana Cloud: diff --git a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md index df7abac34b0..f7d1214271a 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -161,7 +161,7 @@ You can find the public data sources that support alert rules in the [Grafana Pl In Grafana Cloud, the number of Grafana-managed alert rules you can create depends on your Grafana Cloud plan. - Free Forever plan: You can create up to 100 free alert rules, with each alert rule having a maximum of 1000 alert instances. -- All paid plans (Pro and Advanced): They have a soft limit of 2000 alert rules and support unlimited alert instances. To increase the limit, open a support ticket from the [Cloud portal](/docs/grafana-cloud/account-management/support/). +- All paid plans: They have a soft limit of 2000 alert rules and support unlimited alert instances. To increase the limit, open a support ticket from the [Cloud portal](/docs/grafana-cloud/account-management/support/). ### Permissions diff --git a/docs/sources/alerting/alerting-rules/link-alert-rules-to-panels.md b/docs/sources/alerting/alerting-rules/link-alert-rules-to-panels.md index 052770cc058..8be72c73270 100644 --- a/docs/sources/alerting/alerting-rules/link-alert-rules-to-panels.md +++ b/docs/sources/alerting/alerting-rules/link-alert-rules-to-panels.md @@ -67,6 +67,8 @@ By default, notification messages include a link to the dashboard panel. Additio ## Create alert rules from panels +{{< shared id="create-alert-from-panel" >}} + To streamline alert creation, you can create an alert rule directly from a panel. 1. Navigate to a dashboard in the **Dashboards** section. @@ -77,6 +79,8 @@ To streamline alert creation, you can create an alert rule directly from a panel - Sets the alert rule query using the panel query. 1. Complete the alert rule configuration and click **Save rule** to initiate the alert rule. +{{< /shared >}} + You can then [view the alert state on the panel](ref:view-alert-state-on-panels). By default, notification messages include a link to the dashboard panel. Additionally, you can [enable displaying panel screenshots in notifications](ref:images-in-notifications). diff --git a/docs/sources/alerting/best-practices/_index.md b/docs/sources/alerting/best-practices/_index.md index 18bcf13121a..41251a25994 100644 --- a/docs/sources/alerting/best-practices/_index.md +++ b/docs/sources/alerting/best-practices/_index.md @@ -21,6 +21,8 @@ This section provides a set of guides and examples of best practices for Grafana Designing and configuring an alert management set up that works takes time. Here are some additional tips on how to create an effective alert management set up: +{{< shared id="alert-planning-fundamentals" >}} + **Which are the key metrics for your business that you want to monitor and alert on?** - Find events that are important to know about and not so trivial or frequent that recipients ignore them. @@ -44,3 +46,5 @@ Designing and configuring an alert management set up that works takes time. Here - Avoid noisy, unnecessary alerts by using silences, mute timings, or pausing alert rule evaluation. - Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts. - Continually review your thresholds and evaluation rules. + +{{< /shared >}} diff --git a/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md b/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md index 80a58728bc1..7ff256ac103 100644 --- a/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rule-evaluation/_index.md @@ -81,30 +81,40 @@ Alert instances are routed for [notifications](ref:notifications) in two scenari ## Evaluation group -Every alert rule and recording rule is assigned to an evaluation group. +{{< shared id="evaluation-group-basics" >}} -Each evaluation group contains an **evaluation interval** that determines how frequently the rule is checked. For instance, the evaluation may occur every `10s`, `30s`, `1m`, `10m`, etc. +Every alert rule and recording rule is assigned to an evaluation group. Each evaluation group contains an **evaluation interval** that determines how frequently the rule is checked. For instance, the evaluation may occur every `10s`, `30s`, `1m`, `10m`, etc. + +{{< /shared >}} Rules can be evaluated concurrently or sequentially. For details, see [How rules are evaluated within a group](ref:evaluation-within-a-group). ## Pending period +{{< shared id="pending-period-basics" >}} + You can set a **Pending period** to prevent unnecessary notifications caused by temporary issues. When the alert condition is met, the alert instance enters the **Pending** state. It remains in this state until the condition has been continuously true for the entire **Pending period**. This ensures the condition breach is stable before the alert transitions to the **Alerting** state and routed for notification. +{{< /shared >}} + - **Normal** -> **Pending** -> **Alerting**\* You can also set the **Pending period** to zero to skip the **Pending** state entirely and transition to **Alerting** immediately. ## Keep firing for +{{< shared id="keep-firing-for" >}} + You can set a **Keep firing for** period to avoid repeated firing-resolving-firing notifications caused by flapping conditions. When the alert condition is no longer met during the **Alerting** state, the alert instance enters the **Recovering** state. +{{< /shared >}} + - **Alerting** → **Recovering** → **Normal (Resolved)**\* - After the **Keep firing for** period elapses, the alert transitions to the **Normal** state and is marked as **Resolved**. - If the alert condition is met again, the alert transitions back to the **Alerting** state, and no new notifications are sent. diff --git a/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md b/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md index 8d0aca31034..27ae61e3cb9 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md +++ b/docs/sources/alerting/fundamentals/alert-rules/annotation-label.md @@ -62,10 +62,14 @@ Labels and annotations add additional information about an alert using key/value ## Labels +{{< shared id="labels-basics" >}} + **Labels** are unique identifiers of an [alert instance](ref:alert-instances). You can use them for searching, silencing, and routing notifications. Examples of labels are `server=server1` or `team=backend`. Each alert rule can have more than one label and the complete set of labels for an alert rule is called its label set. It is this label set that identifies the alert. +{{< /shared >}} + For example, an alert instance might have the label set `{alertname="High CPU usage",server="server1"}` while another alert instance might have the label set `{alertname="High CPU usage",server="server2"}`. These are two separate alert instances because although their `alertname` labels are the same, their `server` labels are different. {{< figure alt="Image shows an example of an alert instance and the labels used on the alert instance." src="/static/img/docs/alerting/unified/multi-dimensional-alert.png" >}} @@ -134,6 +138,8 @@ If multiple label keys are sanitized to the same value, the duplicates have a sh ## Annotations +{{< shared id="annotations-basics" >}} + Annotations add additional information to alert instances, helping responders identify and address potential issues. Create clear and self-explanatory annotations so that first responders can investigate without needing deeper knowledge of the alert setup. @@ -145,6 +151,8 @@ Annotations are displayed in Grafana and are included by default in notification - `runbook_url`: The runbook page to guide operators managing a potential incident. - `__dashboardUid__` and `__panelId__`: [Link the alert to a dashboard and panel](ref:link-alert-rules-to-panels) to facilitate alert investigation. +{{< /shared >}} + For example, you can edit the annotation `summary` to explain why the alert was triggered: ``` diff --git a/docs/sources/alerting/fundamentals/notifications/_index.md b/docs/sources/alerting/fundamentals/notifications/_index.md index 3a0599722d3..fd8a77448b8 100644 --- a/docs/sources/alerting/fundamentals/notifications/_index.md +++ b/docs/sources/alerting/fundamentals/notifications/_index.md @@ -83,10 +83,14 @@ Start defining your [contact points](ref:contact-points) to specify how to recei ### Contact points +{{< shared id="contact-points-fundamentals" >}} + [Contact points](ref:contact-points) contain the configuration for sending alert notifications, specifying destinations like email, Slack, IRM, webhooks, and their notification messages. A contact point is a list of integrations, each sending a message to a specific destination. +{{< /shared >}} + By default, notification messages include common alert details, such as the number of alerts, alert names, labels, annotations, and other alert information. You can also customize notification messages and use notification templates. First, create the contact point and test the notifications. Then, configure the alert rule to send its notifications to either a contact point or through Notification Policies. diff --git a/docs/sources/datasources/elasticsearch/template-variables/index.md b/docs/sources/datasources/elasticsearch/template-variables/index.md index b27861f0796..66ca17a93bd 100644 --- a/docs/sources/datasources/elasticsearch/template-variables/index.md +++ b/docs/sources/datasources/elasticsearch/template-variables/index.md @@ -86,8 +86,6 @@ You can alternatively use other sorting criteria, such as **Alphabetical**, to r In the above example, a Lucene query filters documents based on the `hostname` property using a variable named `$hostname`. The example also uses a variable in the _Terms_ group by field input box, which you can use to quickly change how data is grouped. -To view an example dashboard on Grafana Play, see the [Elasticsearch Templated Dashboard](https://play.grafana.org/d/z8OZC66nk/elasticsearch-8-2-0-sample-flight-data?orgId=1). - ## Create a query Write the query using a custom JSON string, with the field mapped as a [keyword](https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html#keyword) in the Elasticsearch index mapping. diff --git a/docs/sources/datasources/mssql/_index.md b/docs/sources/datasources/mssql/_index.md index 9909696daee..a5e00da6dcb 100644 --- a/docs/sources/datasources/mssql/_index.md +++ b/docs/sources/datasources/mssql/_index.md @@ -49,148 +49,78 @@ refs: destination: /docs/grafana//administration/provisioning/#data-sources - pattern: /docs/grafana-cloud/ destination: /docs/grafana//administration/provisioning/#data-sources + transformations: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/query-transform-data/transform-data/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data/ + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/ + visualizations: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/visualizations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ + variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/variables/ + annotate-visualizations: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/ + set-up-grafana-monitoring: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/set-up-grafana-monitoring/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/set-up-grafana-monitoring/ + configure-mssql-data-source: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/configure + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/configure + mssql-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/query-editor/ + mssql-template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/template-variables/ + query-caching: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching --- -# Microsoft SQL Server data source +# Microsoft SQL Server (MSSQL) data source -Grafana ships with built-in support for Microsoft SQL Server (MS SQL). -You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database. +Grafana ships with built-in support for Microsoft SQL Server (MSSQL). +You can query and visualize data from any Microsoft SQL Server 2005 or newer, including the Microsoft Azure SQL Database. -This topic explains configuration specific to the Microsoft SQL Server data source. +Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time. -For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management). -Only users with the organization administrator role can add data sources. -Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning system. +The following documentation helps you get started working with the Microsoft SQL Server (MSSQL) data source: -Once you've added the Microsoft SQL Server data source, you can [configure it](#configure-the-data-source) so that your Grafana instance's users can create queries in its [query editor](query-editor/) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore). +- [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source) +- [Microsoft SQL Server query editor](ref:mssql-query-editor) +- [Microsoft SQL Server template variables](ref:mssql-template-variables) -## Configure the data source +## Get the most out of the data source -To configure basic settings for the data source, complete the following steps: +After installing and configuring the Microsoft SQL Server data source, you can: -1. Click **Connections** in the left-side menu. -1. Under Your connections, click **Data sources**. -1. Enter `Microsoft SQL Server` in the search bar. -1. Select **Microsoft SQL Server**. - - The **Settings** tab of the data source is displayed. - -1. Set the data source's basic configuration options: - -| Name | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Name** | Sets the name you use to refer to the data source in panels and queries. | -| **Default** | Sets the data source that's pre-selected for new panels. | -| **Host** | Sets the IP address/hostname and optional port of your MS SQL instance. Default port is 0, the driver default. You can specify multiple connection properties, such as `ApplicationIntent`, by separating each property with a semicolon (`;`). | -| **Database** | Sets the name of your MS SQL database. | -| **Authentication** | Sets the authentication mode, either using SQL Server authentication, Windows authentication (single sign-on for Windows users), Azure Active Directory authentication, or various forms of Windows Active Directory authentication. | -| **User** | Defines the database user's username. | -| **Password** | Defines the database user's password. | -| **Encrypt** | Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server. Options include: `disable` - data sent between client and server is not encrypted; `false` - data sent between client and server is not encrypted beyond the login packet; `true` - data sent between client and server is encrypted. Default is `false`. | -| **Max open** | Sets the maximum number of open connections to the database. Default is `100`. | -| **Max idle** | Sets the maximum number of connections in the idle connection pool. Default is `100`. | -| **Auto (max idle)** | If set will set the maximum number of idle connections to the number of maximum open connections. Default is `true`. | -| **Max lifetime** | Sets the maximum number of seconds that the data source can reuse a connection. Default is `14400` (4 hours). | - -You can also configure settings specific to the Microsoft SQL Server data source. These options are described in the sections below. - -### Min time interval - -The **Min time interval** setting defines a lower limit for the [`$__interval`](ref:add-template-variables-interval) and [`$__interval_ms`][add-template-variables-interval_ms] variables. - -This value _must_ be formatted as a number followed by a valid time identifier: - -| Identifier | Description | -| ---------- | ----------- | -| `y` | year | -| `M` | month | -| `w` | week | -| `d` | day | -| `h` | hour | -| `m` | minute | -| `s` | second | -| `ms` | millisecond | - -We recommend setting this value to match your Microsoft SQL Server write frequency. -For example, use `1m` if Microsoft SQL Server writes data every minute. - -You can also override this setting in a dashboard panel under its data source options. - -### Connection timeout - -The **Connection timeout** setting defines the maximum number of seconds to wait for a connection to the database before timing out. Default is 0 for no timeout. - -### UDP Preference Limit - -The **UDP Preference Limit** setting defines the maximum size packet that the Kerberos libraries will attempt to send over a UDP connection before retrying with TCP. Default is 1 which means always use TCP. - -### DNS Lookup KDC - -The **DNS Lookup KDC** setting controls whether to [lookup KDC in DNS](https://web.mit.edu/kerberos/krb5-latest/doc/admin/realm_config.html#mapping-hostnames-onto-kerberos-realms). Default is true. - -### KRB5 config file path - -The **KRB5 config file path** stores the location of the `krb5` config file. Default is `/etc/krb5.conf` - -### Database user permissions - -Grafana doesn't validate that a query is safe, and could include any SQL statement. -For example, Microsoft SQL Server would execute destructive queries like `DELETE FROM user;` and `DROP TABLE user;` if the querying user has permission to do so. - -To protect against this, we strongly recommend that you create a specific MS SQL user with restricted permissions. - -Grant only `SELECT` permissions on the specified database and tables that you want to query to the database user you specified when you added the data source: - -```sql -CREATE USER grafanareader WITH PASSWORD 'password' -GRANT SELECT ON dbo.YourTable3 TO grafanareader -``` - -Also, ensure that the user doesn't have any unwanted privileges from the public role. - -### Diagnose connection issues - -If you use older versions of Microsoft SQL Server, such as 2008 and 2008R2, you might need to disable encryption before you can connect the data source. - -We recommend that you use the latest available service pack for optimal compatibility. - -### Provision the data source - -You can define and configure the data source in YAML files as part of Grafana's provisioning system. -For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-data-sources). - -#### Provisioning example - -```yaml -apiVersion: 1 - -datasources: - - name: MSSQL - type: mssql - url: localhost:1433 - user: grafana - jsonData: - database: grafana - maxOpenConns: 100 - maxIdleConns: 100 - maxIdleConnsAuto: true - connMaxLifetime: 14400 - connectionTimeout: 0 - encrypt: 'false' - secureJsonData: - password: 'Password!' -``` - -## Query the data source - -You can create queries with the Microsoft SQL Server data source's query editor when editing a panel that uses a MS SQL data source. - -For details, refer to the [query editor documentation](query-editor/). - -## Use template variables - -Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables. -Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard. -Grafana refers to such variables as template variables. - -For details, see the [template variables documentation](template-variables/). +- Create a wide variety of [visualizations](ref:visualizations) +- Configure and use [templates and variables](ref:variables) +- Add [transformations](ref:transformations) +- Add [annotations](ref:annotate-visualizations) +- Set up [alerting](ref:alerting) +- Optimize performance with [query caching](ref:query-caching) diff --git a/docs/sources/datasources/mssql/configure/index.md b/docs/sources/datasources/mssql/configure/index.md new file mode 100644 index 00000000000..7fc8e5920c3 --- /dev/null +++ b/docs/sources/datasources/mssql/configure/index.md @@ -0,0 +1,251 @@ +--- +aliases: + - ../../data-sources/mssql/ +description: This document provides instructions for configuring the MSSQL data source. +keywords: + - grafana + - MSSQL + - Microsoft + - SQL + - guide + - Azure SQL Database + - queries +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Configure +title: Configure the Microsoft SQL Server data source +weight: 200 +refs: + query-transform-data: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/query-transform-data/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//panels-visualizations/query-transform-data/ + table: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/visualizations/table/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//panels-visualizations/visualizations/table/ + configure-standard-options-display-name: + - pattern: /docs/grafana/ + destination: /docs/grafana//panels-visualizations/configure-standard-options/#display-name + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//panels-visualizations/configure-standard-options/#display-name + annotate-visualizations: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + data-source-management: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/data-source-management/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/data-source-management/ + private-data-source-connect: + - pattern: /docs/grafana/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + configure-pdc: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc + provision-grafana: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/provisioning/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/provisioning/ + add-template-variables-interval-ms: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/variables/add-template-variables/#__interval_ms + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/variables/add-template-variables/#__interval_ms + add-template-variables-interval: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/variables/add-template-variables/#__interval + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//dashboards/variables/add-template-variables/#__interval + data-sources: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/ +--- + +# Configure the Microsoft SQL Server data source + +This document provides instructions for configuring the Microsoft SQL Server data source and explains available configuration options. For general information on adding and managing data sources, refer to [Grafana data sources](ref:data-sources) and [Data source management](ref:data-source-management). + +## Before you begin + +- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin. + +- You must have the `Organization administrator` role to configure the MSSQL data source. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. + +- Familiarize yourself with your MSSQL security configuration and gather any necessary security certificates and client keys. + +- Verify that data from MSSQL is being written to your Grafana instance. + +## Add the MSSQL data source + +To add the MSSQL data source, complete the following steps: + +1. Click **Connections** in the left-side menu. +1. Click **Add new connection** +1. Type `Microsoft SQL Server` in the search bar. +1. Select **Microsoft SQL Server** under data source. +1. Click **Add new data source** in the upper right. + +Grafana takes you to the **Settings** tab, where you will set up your Microsoft SQL Server configuration. + +## Configure the data source in the UI + +Following are configuration options for the Microsoft SQL Server data source. + +{{< admonition type="warning" >}} +Kerberos is not supported in Grafana Cloud. +{{< /admonition >}} + +| **Setting** | **Description** | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **Name** | The data source name. Sets the name you use to refer to the data source in panels and queries. Examples: `MSSQL-1`, `MSSQL_Sales1`. | +| **Default** | Toggle to select as the default name in dashboard panels. When you go to a dashboard panel, this will be the default selected data source. | + +**Connection:** + +| Setting | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Host** | Sets the IP address or hostname (and optional port) of your MSSQL instance. The default port is `0`, which uses the driver's default.
You can include additional connection properties (e.g., `ApplicationIntent`) by separating them with semicolons (`;`). | +| **Database** | Sets the name of the MSSQL database to connect to. | + +**TLS/SSL Auth:** + +Encrypt - Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server. + +| Encrypt Setting | Description | +| --------------- | ------------------------------------------------------------------------------------------------ | +| **Disable** | Data sent between the client and server is **not encrypted**. | +| **False** | The default setting. Only the login packet is encrypted; **all other data is sent unencrypted**. | +| **True** | **All data** sent between the client and server is **encrypted**. | + +{{< admonition type="note" >}} +If you're using an older version of Microsoft SQL Server like 2008 and 2008R2, you may need to disable encryption to be able to connect. +{{< /admonition >}} + +**Authentication:** + +| Authentication Type | Description | Credentials / Fields | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **SQL Server Authentication** | Default method to connect to MSSQL. Use a SQL Server or Windows login in `DOMAIN\User` format. | - **Username**: SQL Server username
- **Password**: SQL Server password | +| **Windows Authentication**
(Integrated Security) | Uses the logged-in Windows user's credentials via single sign-on. Available only when SQL Server allows Windows Authentication. | No input required; uses the logged-in Windows user's credentials | +| **Windows AD**
(Username/Password) | Authenticates a domain user with their Active Directory username and password. | - **Username**: `user@example.com`
- **Password**: Active Directory password | +| **Windows AD**
(Keytab) | Authenticates a domain user using a keytab file. | - **Username**: `user@example.com`
- **Keytab file path**: Path to your keytab file | +| **Windows AD**
(Credential Cache) | Uses a Kerberos credential cache already loaded in memory (e.g., from a prior `kinit` command). No file needed. | - **Credential cache path**: Path to in-memory credential (e.g., `/tmp/krb5cc_1000`) | +| **Windows AD**
(Credential Cache File) | Authenticates a domain user using a credential cache file (`.ccache`). | - **Username**: `user@example.com`
- **Credential cache file path**: e.g., `/home/grot/cache.json` | + +**Additional settings:** + +Additional settings are optional settings you configure for more control over your data source. This includes connection limits, connection timeout, group-by time interval, and Secure Socks Proxy. + +**Connection limits**: + +| Setting | Description | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Max open** | The maximum number of open connections to the database. If set to `0`, there is no limit. If `max open` is greater than `0` and less than `max idle`, `max idle` is adjusted to match. | +| **Auto max idle** | When enabled, automatically sets `max idle` to match `max open`. If `max open` isn’t set, it defaults to `100`. | +| **Max idle** | The maximum number of idle connections in the pool. If `max open` is set and is lower than `max idle`, then `max idle` is reduced to match. If set to `0`, no idle connections are retained. | +| **Max lifetime** | The maximum time (in seconds) a connection can be reused before being closed and replaced. If set to `0`, connections are reused indefinitely. | + +**Connection details:** + +| **Setting** | **Description** | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Min time interval** | Specifies the lower bound for the auto-generated `GROUP BY` time interval. Grafana recommends matching this value to your data's write frequency—for example, `1m` if data is written every minute. Refer to [Min time interval](#min-time-interval) for details. | +| **Connection timeout** | Specifies the maximum number of seconds to wait when attempting to connect to the database before timing out. A value of `0` (the default) disables the timeout. | + +**Windows ADS Advanced Settings** + +| Setting | Description | Default | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| **UDP Preference Limit** | Defines the maximum packet size (in bytes) that Kerberos libraries will attempt to send over UDP before retrying with TCP. A value of `1` forces all communication to use TCP. | `1` (always use TCP) | +| **DNS Lookup KDC** | Controls whether DNS `SRV` records are used to locate [Key Distribution Centers (KDCs)](https://web.mit.edu/kerberos/krb5-latest/doc/admin/realm_config.html#key-distribution-centers) and other servers for the realm. | `true` | +| **krb5 config file path** | Specifies the path to the Kerberos configuration file used by the [MIT krb5 package](https://web.mit.edu/kerberos/krb5-1.12/doc/admin/conf_files/krb5_conf.html). | `/etc/krb5.conf` | + +**Private data source connect** - _Only for Grafana Cloud users._ + +Private data source connect, or PDC, allows you to establish a private, secured connection between a Grafana Cloud instance, or stack, and data sources secured within a private network. Click the drop-down to locate the URL for PDC. For more information regarding Grafana PDC refer to [Private data source connect (PDC)](ref:private-data-source-connect) and [Configure Grafana private data source connect (PDC)](ref:configure-pdc) for instructions on setting up a PDC connection. + +Click **Manage private data source connect** to open your PDC connection page and view your configuration details. + +After configuring your MSSQL data source options, click **Save & test** at the bottom to test the connection. You should see a confirmation dialog box that says: + +**Database Connection OK** + +### Min time interval + +The **Min time interval** setting defines a lower limit for the [`$__interval`](ref:add-template-variables-interval) and [`$__interval_ms`][add-template-variables-interval_ms] variables. + +This value _must_ be formatted as a number followed by a valid time identifier: + +| Identifier | Description | +| ---------- | ----------- | +| `y` | year | +| `M` | month | +| `w` | week | +| `d` | day | +| `h` | hour | +| `m` | minute | +| `s` | second | +| `ms` | millisecond | + +Grafana recommends setting this value to match your Microsoft SQL Server write frequency. +For example, use `1m` if Microsoft SQL Server writes data every minute. + +You can also override this setting in a dashboard panel under its data source options. + +### Database user permissions + +When adding a data source, ensure the database user you specify has only SELECT permissions on the relevant database and tables. Grafana does not validate the safety of queries, which means they can include potentially harmful SQL statements, such as `USE otherdb`; or `DROP TABLE user;`, which could get executed. To minimize this risk, Grafana strongly recommends creating a dedicated MySQL user with restricted permissions. + +```sql +CREATE USER grafanareader WITH PASSWORD 'password' +GRANT SELECT ON dbo.YourTable3 TO grafanareader +``` + +Also, ensure that the user doesn't have any unwanted privileges from the public role. + +### Diagnose connection issues + +If you use older versions of Microsoft SQL Server, such as 2008 and 2008R2, you might need to disable encryption before you can connect the data source. + +Grafana recommends that you use the latest available service pack for optimal compatibility. + +### Provision the data source + +You can define and configure the data source in YAML files as part of the Grafana provisioning system. For more information about provisioning, and for available configuration options, refer to [Provision Grafana](ref:provision-grafana). + +#### Provisioning example + +```yaml +apiVersion: 1 + +datasources: + - name: MSSQL + type: mssql + url: localhost:1433 + user: grafana + jsonData: + database: grafana + maxOpenConns: 100 + maxIdleConns: 100 + maxIdleConnsAuto: true + connMaxLifetime: 14400 + connectionTimeout: 0 + encrypt: 'false' + secureJsonData: + password: 'Password!' +``` diff --git a/docs/sources/datasources/mssql/query-editor/index.md b/docs/sources/datasources/mssql/query-editor/index.md index d582f7be4b3..6a6c00c526c 100644 --- a/docs/sources/datasources/mssql/query-editor/index.md +++ b/docs/sources/datasources/mssql/query-editor/index.md @@ -39,64 +39,94 @@ refs: destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//dashboards/build-dashboards/annotate-visualizations/ + explore: + - pattern: /docs/grafana/ + destination: /docs/grafana//explore/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//explore/ --- # Microsoft SQL Server query editor -You can create queries with the Microsoft SQL Server data source's query editor when editing a panel that uses a MS SQL data source. +Grafana provides a query editor for the Microsoft SQL Server data source, which is located on the [Explore page](ref:explore). You can also access the MSSQL query editor from a dashboard panel. Click the menu in the upper right of the panel and select **Edit**. -This topic explains querying specific to the MS SQL data source. -For general documentation on querying data sources in Grafana, see [Query and transform data](ref:query-transform-data). +This topic explains querying specific to the MSSQL data source. +For general documentation on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data). For options and functions common to all query editors, refer to [Query editors](ref:query-transform-data). -## Choose a query editing mode +For more information on writing Transact-SQL statements, refer to [Write Transact-SQL statements](https://learn.microsoft.com/en-us/sql/t-sql/tutorial-writing-transact-sql-statements?view=sql-server-ver17) and [Transact-SQL reference](https://learn.microsoft.com/en-us/sql/t-sql/language-reference?view=sql-server-ver17) in the Microsoft SQL Server documentation. -You can switch the query editor between two modes: +The Microsoft SQL Server query editor has two modes: -- [Code mode](#code-mode), which provides a feature-rich editor for writing queries -- [Builder mode](#builder-mode), which provides a visual query designer +- [Builder mode](#builder-mode) +- [Code mode](#code-mode) -To switch between the editor modes, select the corresponding **Builder** and **Code** tabs above the editor. +To switch between the editor modes, select the corresponding **Builder** and **Code** tabs in the upper right. -To run a query, select **Run query** located at the top right corner of the editor. +![MSSQL query builder](/media/mssql/mssql-query-editor-v12.png) -The query editor also provides: +{{< admonition type="warning" >}} +When switching from **Code** mode to **Builder** mode, any changes made to your SQL query aren't saved and will not be shown in the builder interface. You can choose to copy your code to the clipboard or discard the changes. +{{< /admonition >}} -- [Macros](#use-macros) +To run a query, select **Run query** in the upper right of the editor. + +In addition to writing queries, the query editor also allows you to create and use: + +- [Macros](#macros) - [Annotations](#apply-annotations) - [Stored procedures](#use-stored-procedures) -## Configure common options +## Builder mode -You can configure a MS SQL-specific response format in the query editor regardless of its mode. +**Builder mode** allows you to build queries using a visual interface. This mode is great for users who prefer a guided query experience or are just getting started with SQL. -### Choose a response format +{{< figure alt="MSSQL builder mode>" src="/media/docs/mssql/mssql-builder-mode-v12.png" class="docs-image--no-shadow" >}} -Grafana can format the response from MS SQL as either a table or as a time series. +The following components will help you build a T-SQL query: -To choose a response format, select either the **Table** or **Time series** formats from the **Format** dropdown. +- **Format** - Select a format response from the drop-down for the MSSQL query. The default is **Table**. Refer to [Table queries](#table-queries) and [Time series queries](#time-series-queries) for more information and examples. If you select the **Time series** format option, you must include a `time` column. -To use the time series format, you must name one of the MS SQL columns `time`. -You can use time series queries, but not table queries, in alerting conditions. +- **Dataset** - Select a database to query from the drop-down. Grafana automatically populates the drop-down with all databases the user has access to. If a default database is configured in the Data Source Configuration page or via a provisioning file, users will be limited to querying only that predefined database. -For details about using these formats, refer to [Use table queries](#use-table-queries) and [Use time series queries](#use-time-series-queries). + Note that `tempdb`, `model`, `msdb`, and `master` system databases are not included in the query editor drop-down. + +- **Table** - Select a table from the drop-down. After selecting a database, the next drop-down displays all available tables in that database. + +- **Data operations** - _Optional_. Select an aggregation or a macro from the drop-down. You can add multiple data operations by clicking the **+ sign**. Click the **garbage can icon** to remove data operations. + - **Column** - Select a column on which to run the aggregation. + - **Interval** - Select an interval from the drop-down. You'll see this option when you choose a `time group` macro from the drop-down. + - **Fill** - _Optional_. Add a `FILL` method to populate missing time intervals with default values (such as NULL, 0, or a specified value) when no data exists for those intervals. This ensures continuity in the time series, avoiding gaps in visualizations. + - **Alias** - _Optional_. Add an alias from the drop-down. You can also add your own alias by typing it in the box and clicking **Enter**. Remove an alias by clicking the **X**. + +- **Filter** - Toggle to add filters. + - **Filter by column value** - _Optional_. If you toggle **Filter** you can add a column to filter by from the drop-down. To filter by additional columns, click the **+ sign** to the right of the condition drop-down. You can choose a variety of operators from the drop-down next to the condition. When multiple filters are added, use the `AND` or `OR` operators to define how conditions are evaluated. `AND` requires all conditions to be true, while `OR` requires any condition to be true. Use the second drop-down to select the filter value. To remove a filter, click the **X icon** next to it. If you select a `date-type` column, you can use macros from the operator list and choose `timeFilter` to insert the `$\_\_timeFilter` macro into your query with the selected date column. + + After selecting a date type column, you can choose Macros from the operators list and select timeFilter which will add the `$\_\_timeFilter` macro to the query with the selected date column. Refer to [Macros](#macros) for more information. + +- **Group** - Toggle to add a `GROUP BY` column. + - **Group by column** - Select a column to filter by from the drop-down. Click the **+sign** to filter by multiple columns. Click the **X** to remove a filter. +- **Order** - Toggle to add an `ORDER BY` statement. + - **Order by** - Select a column to order by from the drop-down. Select ascending (`ASC`) or descending (`DESC`) order. + - **Limit** - You can add an optional limit on the number of retrieved results. Default is 50. +- **Preview** - Toggle for a preview of the SQL query generated by the query builder. Preview is toggled on by default. + +For additional detail about using formats, refer to [Table queries](#table-queries) and [Time series queries](#time-series-queries). ## Code mode {{< figure src="/static/img/docs/v92/sql_code_editor.png" class="docs-image--no-shadow" >}} -In **Code mode**, you can write complex queries using a text editor with autocompletion features and syntax highlighting. +**Code mode** lets you build complex queries using a text editor with helpful features like autocompletion and syntax highlighting. -For more information about Transact-SQL (T-SQL), the query language used by Microsoft SQL Server, refer to the [Transact-SQL tutorial](https://learn.microsoft.com/en-us/sql/t-sql/tutorial-writing-transact-sql-statements). +This mode is ideal for advanced users who need full control over the SQL query or want to use features not available in visual query mode. It’s especially useful for writing subqueries, using macros, or applying advanced filtering and formatting. You can switch back to visual mode, but note that some custom queries may not be fully compatible. -### Use toolbar features +### Code mode toolbar features Code mode has several features in a toolbar located in the editor's lower-right corner. -To reformat the query, click the brackets button (`{}`). - -To expand the code editor, click the chevron button pointing downward. - -To run the query, click the **Run query** button or use the keyboard shortcut Ctrl/Cmd + Enter/Return. +- To reformat the query, click the brackets button (`{}`). +- To expand the code editor, click the chevron button pointing downward. +- To run the query, click the **Run query** button or use the keyboard shortcut **Ctrl/Cmd + Enter/Return**. ### Use autocompletion @@ -105,94 +135,47 @@ To manually trigger autocompletion, use the keyboard shortcut Ctrl/ **Note:** You can't autocomplete columns until you've specified a table. +{{< admonition type="note" >}} +You can't autocomplete columns until you've specified a table. +{{< /admonition >}} -## Builder mode - -{{< figure src="/static/img/docs/v92/mssql_query_builder.png" class="docs-image--no-shadow" >}} - -In **Builder mode**, you can build queries using a visual interface. - -### Dataset and table selection - -In the **Dataset** dropdown, select the MSSQL database to query. Grafana populates the dropdown with all databases that the user can access. -Once you select a database, Grafana populates the dropdown with all available tables. - -**Note:** If a default database has been configured through the Data Source Configuration page (or through a provisioning configuration file), the user will only be able to use that single preconfigured database for querying. - -We don't include `tempdb`,`model`,`msdb`,`master` databases in the query editor dropdown. - -### Select columns and aggregation functions (SELECT) - -Select a column from the **Column** dropdown to include it in the data. -You can select an optional aggregation function for the column in the **Aggregation** dropdown. - -To add more value columns, click the plus (`+`) button to the right of the column's row. - -{{< docs/shared source="grafana" lookup="datasources/sql-query-builder-macros.md" version="" >}} - -### Filter data (WHERE) - -To add a filter, toggle the **Filter** switch at the top of the editor. -This reveals a **Filter by column value** section with two dropdown selectors. - -Use the first dropdown to choose whether all of the filters need to match (`AND`), or if only one of the filters needs to match (`OR`). -Use the second dropdown to choose a filter. - -To filter on more columns, click the plus (`+`) button to the right of the condition dropdown. - -To remove a filter, click the `x` button next to that filter's dropdown. - -After selecting a date type column, you can choose Macros from the operators list and select timeFilter which will add the $\_\_timeFilter macro to the query with the selected date column. - -### Group results - -To group results by column, toggle the **Group** switch at the top of the editor. -This reveals a **Group by column** dropdown where you can select which column to group the results by. - -To remove the group-by clause, click the `x` button. - -### Preview the query - -To preview the SQL query generated by Builder mode, toggle the **Preview** switch at the top of the editor. -This reveals a preview pane containing the query, and an copy icon at the top right that copies the query to your clipboard. - -## Use macros +## Macros To simplify syntax and to allow for dynamic components, such as date range filters, you can add macros to your query. -| Macro example | Replaced by | -| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `$__time(dateColumn)` | An expression to rename the column to _time_. For example, _dateColumn as time_ | -| `$__timeEpoch(dateColumn)` | An expression to convert a DATETIME column type to Unix timestamp and rename it to _time_.
For example, _DATEDIFF(second, '1970-01-01', dateColumn) AS time_ | -| `$__timeFilter(dateColumn)` | A time range filter using the specified column name.
For example, _dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'_ | -| `$__timeFrom()` | The start of the currently active time selection. For example, _'2017-04-21T05:01:17Z'_ | -| `$__timeTo()` | The end of the currently active time selection. For example, _'2017-04-21T05:06:17Z'_ | -| `$__timeGroup(dateColumn,'5m'[, fillvalue])` | An expression usable in GROUP BY clause. Providing a _fillValue_ of _NULL_ or _floating value_ will automatically fill empty series in timerange with that value.
For example, _CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)\*300_. | -| `$__timeGroup(dateColumn,'5m', 0)` | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value. | -| `$__timeGroup(dateColumn,'5m', NULL)` | Same as above but NULL will be used as value for missing points. | -| `$__timeGroup(dateColumn,'5m', previous)` | Same as above but the previous value in that series will be used as fill value if no value has been seen yet NULL will be used. | -| `$__timeGroupAlias(dateColumn,'5m')` | Same as `$__timeGroup` but with an added column alias. | -| `$__unixEpochFilter(dateColumn)` | A time range filter using the specified column name with times represented as Unix timestamp. For example, _dateColumn > 1494410783 AND dateColumn < 1494497183_ | -| `$__unixEpochFrom()` | The start of the currently active time selection as Unix timestamp. For example, _1494410783_ | -| `$__unixEpochTo()` | The end of the currently active time selection as Unix timestamp. For example, _1494497183_ | -| `$__unixEpochNanoFilter(dateColumn)` | A time range filter using the specified column name with times represented as nanosecond timestamp. For example, _dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872_ | -| `$__unixEpochNanoFrom()` | The start of the currently active time selection as nanosecond timestamp. For example, _1494410783152415214_ | -| `$__unixEpochNanoTo()` | The end of the currently active time selection as nanosecond timestamp. For example, _1494497183142514872_ | -| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as `$__timeGroup` but for times stored as Unix timestamp. | -| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as above but also adds a column alias. | +Use macros in the `SELECT` clause to simplify the creation of time series queries. +From the **Data operations** drop-down, choose a macro such as `$\_\_timeGroup` or `$\_\_timeGroupAlias`. Then, select a time column from the **Column** drop-down and a time interval from the **Interval** drop-down. This generates a time-series query based on your selected time grouping. + +| **Macro** | **Description** | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `$__time(dateColumn)` | Renames the specified column to `_time`.
Example: `dateColumn AS time` | +| `$__timeEpoch(dateColumn)` | Converts a `DATETIME` column to a Unix timestamp and renames it to `_time`.
Example: `DATEDIFF(second, '1970-01-01', dateColumn) AS time` | +| `$__timeFilter(dateColumn)` | Adds a time range filter for the specified column.
Example: `dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'` | +| `$__timeFrom()` | Returns the start of the current time range.
Example: `'2017-04-21T05:01:17Z'` | +| `$__timeTo()` | Returns the end of the current time range.
Example: `'2017-04-21T05:06:17Z'` | +| `$__timeGroup(dateColumn, '5m'[, fillValue])` | Groups the specified time column into intervals (e.g., 5 minutes). Optionally fills gaps with a value like `0`, `NULL`, or `previous`.
Example: `CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) AS bigint) * 300` | +| `$__timeGroup(dateColumn, '5m', 0)` | Same as above, with `0` used to fill missing data points. | +| `$__timeGroup(dateColumn, '5m', NULL)` | Same as above, with `NULL` used for missing data points. | +| `$__timeGroup(dateColumn, '5m', previous)` | Same as above, using the previous value to fill gaps. If no previous value exists, `NULL` is used. | +| `$__timeGroupAlias(dateColumn, '5m')` | Same as `$__timeGroup`, but also adds an alias to the resulting column. | +| `$__unixEpochFilter(dateColumn)` | Adds a time range filter using Unix timestamps.
Example: `dateColumn > 1494410783 AND dateColumn < 1494497183` | +| `$__unixEpochFrom()` | Returns the start of the current time range as a Unix timestamp.
Example: `1494410783` | +| `$__unixEpochTo()` | Returns the end of the current time range as a Unix timestamp.
Example: `1494497183` | +| `$__unixEpochNanoFilter(dateColumn)` | Adds a time range filter using nanosecond-precision Unix timestamps.
Example: `dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872` | +| `$__unixEpochNanoFrom()` | Returns the start of the current time range as a nanosecond Unix timestamp.
Example: `1494410783152415214` | +| `$__unixEpochNanoTo()` | Returns the end of the current time range as a nanosecond Unix timestamp.
Example: `1494497183142514872` | +| `$__unixEpochGroup(dateColumn, '5m', [fillMode])` | Same as `$__timeGroup`, but for Unix timestamps. Optional `fillMode` controls how to handle missing points. | +| `$__unixEpochGroupAlias(dateColumn, '5m', [fillMode])` | Same as above, but adds an alias to the grouped column. | ### View the interpolated query -The query editor also includes a link named **Generated SQL** that appears after running a query while in panel edit mode. -To display the raw interpolated SQL string that the data source executed, click on this link. +The query editor includes a **Generated SQL** link that appears after you run a query while editing a panel. Click this link to view the raw interpolated SQL that Grafana executed, including any macros that were expanded during query processing. -## Use table queries +## Table queries -If the **Format** query option is set to **Table** for a [Table panel](ref:table), you can enter any type of SQL query. -The Table panel then displays the query results with whatever columns and rows are returned. +To create a Table query, set the **Format** option in the query editor to [**Table**](ref:table). This allows you to write any valid SQL query, and the Table panel will display the results using the returned columns and rows. -**Example database table:** +**Example:** ```sql CREATE TABLE [event] ( @@ -220,43 +203,43 @@ SELECT GETDATE(), CAST(GETDATE() AS DATETIME2), CAST(GETDATE() AS SMALLDATETIME), CAST(GETDATE() AS DATE), CAST(GETDATE() AS TIME), SWITCHOFFSET(CAST(GETDATE() AS DATETIMEOFFSET), '-07:00') ``` -Query editor with example query: - -{{< figure src="/static/img/docs/v51/mssql_table_query.png" max-width="500px" class="docs-image--no-shadow" >}} - -The query: +**Example query with output:** ```sql SELECT * FROM [mssql_types] ``` -To control the name of the Table panel columns, use the standard `AS` SQL column selection syntax. +{{< figure src="/static/img/docs/v51/mssql_table_query.png" max-width="500px" class="docs-image--no-shadow" >}} -For example: +Use the keyword `AS` to define an alias in your query to rename a column or table. + +**Example query with output:** ```sql SELECT - c_bit as [column1], c_tinyint as [column2] + c_bit AS [column1], c_tinyint AS [column2] FROM [mssql_types] ``` -The resulting table panel: - {{< figure src="/static/img/docs/v51/mssql_table_result.png" max-width="1489px" class="docs-image--no-shadow" >}} -## Use time series queries +## Time series queries {{< admonition type="note" >}} Store timestamps in UTC to avoid issues with time shifts in Grafana when using non-UTC timezones. {{< /admonition >}} -If you set the **Format** setting in the query editor to **Time series**, then the query must have a column named `time` that returns either a SQL datetime or any numeric datatype representing Unix epoch in seconds. -Result sets of time series queries must also be sorted by time for panels to properly visualize the result. +To create a time series query, set the **Format** option in the query editor to **Time series**. The query must include a column named `time`, which should contain either a SQL `datetime` value or a numeric value representing Unix epoch time in seconds. The result set must be sorted by the `time` column for panels to visualize the data correctly. -A time series query result is returned in a [wide data frame format](https://grafana.com/developers/plugin-tools/key-concepts/data-frames#wide-format). -Any column except time or of type string transforms into value fields in the data frame query result. -Any string column transforms into field labels in the data frame query result. +A time series query returns results[wide data frame format](https://grafana.com/developers/plugin-tools/key-concepts/data-frames#wide-format). + +- Any column except `time` or of the type `string` transforms into value fields in the data frame query result. +- Any string column transforms into field labels in the data frame query result. + +You can enable macro support in the `SELECT` clause to create time series queries more easily. Use the **Data operations** drop-down to choose a macro such as `$\_\_timeGroup` or `$\_\_timeGroupAlias`, then select a time column from the Column drop-down and a time interval from the Interval drop-down. This generates a time-series query based on your selected time grouping. + +{{< docs/shared source="grafana" lookup="datasources/sql-query-builder-macros.md" version="" >}} ### Create a metric query @@ -294,7 +277,7 @@ Data frame result: ### Time series query examples -**Using the fill parameter in the $\_\_timeGroupAlias macro to convert null values to be zero instead:** +**Use the fill parameter in the $\_\_timeGroupAlias macro to convert null values to be zero instead:** ```sql SELECT @@ -325,7 +308,7 @@ Data frame result: +---------------------+---------------------------+---------------------------+ ``` -**Using multiple columns:** +**Use multiple columns:** ```sql SELECT @@ -354,16 +337,16 @@ Data frame result: ## Apply annotations [Annotations](ref:annotate-visualizations) overlay rich event information on top of graphs. -You can add annotation queries in the Dashboard menu's Annotations view. +You can add annotation queries in the Dashboard menu's **Annotations** view. **Columns:** | Name | Description | | --------- | ----------------------------------------------------------------------------------------------------------------- | -| `time` | The name of the date/time field. Could be a column with a native SQL date/time data type or epoch value. | -| `timeend` | Optional name of the end date/time field. Could be a column with a native SQL date/time data type or epoch value. | -| `text` | Event description field. | -| `tags` | Optional field name to use for event tags as a comma separated string. | +| `time` | The name of the date/time field. Can be a column with a native SQL date/time data type or epoch value. | +| `timeend` | _Optional_ name of the end date/time field. Can be a column with a native SQL date/time data type or epoch value. | +| `text` | Field containing the event description. | +| `tags` | _Optional_ field used for event tags, formatted as a comma-separated string. | **Example database tables:** @@ -375,7 +358,7 @@ CREATE TABLE [events] ( ) ``` -We also use the database table defined in [Time series queries](#time-series-queries). +The following example also uses the database table defined in the [Time series queries](#time-series-queries) section. **Example query using time column with epoch values:** @@ -422,16 +405,17 @@ ORDER BY 1 ## Use stored procedures -Stored procedures have been verified to work. -However, please note that we haven't done anything special to support this, so there might be edge cases where it won't work as you would expect. -Stored procedures should be supported in table, time series and annotation queries as long as you use the same naming of columns and return data in the same format as describe above under respective section. +Stored procedures have been verified to work with Grafana queries. However, note that there is no special handling or extended support for stored procedures, so some edge cases may not behave as expected. -Please note that any macro function will not work inside a stored procedure. +Stored procedures can be used in table, time series, and annotation queries, provided that the returned data matches the expected column names and formats described in the relevant previous sections in this document. -### Examples +{{< admonition type="note" >}} +Grafana macro functions do not work inside stored procedures. +{{< /admonition >}} {{< figure src="/static/img/docs/v51/mssql_metrics_graph.png" class="docs-image--no-shadow docs-image--right" >}} -For the following examples, the database table is defined in [Time series queries](#time-series-queries). Let's say that we want to visualize four series in a graph panel, such as all combinations of columns `valueOne`, `valueTwo` and `measurement`. Graph panel to the right visualizes what we want to achieve. To solve this, we need to use two queries: + +For the following examples, the database table is defined in [Time series queries](#time-series-queries). Let's say that we want to visualize four series in a graph panel, such as all combinations of columns `valueOne`, `valueTwo` and `measurement`. Graph panel to the right visualizes what we want to achieve. To solve this, you need to use two queries: **First query:** @@ -465,14 +449,13 @@ GROUP BY ORDER BY 1 ``` -#### Stored procedure using time in epoch format +### Stored procedure with epoch time format -We can define a stored procedure that will return all data we need to render 4 series in a graph panel like above. -In this case the stored procedure accepts two parameters `@from` and `@to` of `int` data types which should be a timerange (from-to) in epoch format -which will be used to filter the data to return from the stored procedure. +You can define a stored procedure to return all the data needed to render multiple series (for example, 4) in a graph panel. -We're mimicking the `$__timeGroup(time, '5m')` in the select and group by expressions, and that's why there are a lot of lengthy expressions needed - -these could be extracted to MS SQL functions, if wanted. +In the following example, the stored procedure accepts two parameters, `@from` and `@to`, both of type `int`. These parameters represent a time range (from–to) in epoch time format and are used to filter the results returned by the procedure. + +The query inside the procedure simulates the behavior of `$__timeGroup(time, '5m')` by grouping timestamps into 5-minute intervals. While the expressions for time grouping are somewhat verbose, they can be extracted into reusable SQL Server functions to simplify the procedure. ```sql CREATE PROCEDURE sp_test_epoch( @@ -507,7 +490,7 @@ BEGIN END ``` -Then we can use the following query for our graph panel. +Then, in your graph panel, you can use the following query to call the stored procedure with the time range dynamically populated by Grafana: ```sql DECLARE @@ -517,14 +500,15 @@ DECLARE EXEC dbo.sp_test_epoch @from, @to ``` -#### Stored procedure using time in datetime format +This uses Grafana built-in macros to convert the selected time range into epoch time ($**unixEpochFrom() and $**unixEpochTo()), which are passed to the stored procedure as input parameters. -We can define a stored procedure that will return all data we need to render 4 series in a graph panel like above. -In this case the stored procedure accepts two parameters `@from` and `@to` of `datetime` data types which should be a timerange (from-to) -which will be used to filter the data to return from the stored procedure. +### Stored procedure with `datetime` format -We're mimicking the `$__timeGroup(time, '5m')` in the select and group by expressions and that's why there's a lot of lengthy expressions needed - -these could be extracted to MS SQL functions, if wanted. +You can define a stored procedure to return all the data needed to render four series in a graph panel. + +In the following example, the stored procedure accepts two parameters, `@from` and `@to`, of the type `datetime`. These parameters represent the selected time range and are used to filter the returned data. + +The query within the procedure mimics the behavior of `$__timeGroup(time, '5m')` by grouping data into 5-minute intervals. These expressions can be verbose, but you may extract them into reusable SQL Server functions for improved readability and maintainability. ```sql CREATE PROCEDURE sp_test_datetime( @@ -560,7 +544,7 @@ END ``` -Then we can use the following query for our graph panel. +To call this stored procedure from a graph panel, use the following query with Grafana built-in macros to populate the time range dynamically: ```sql DECLARE diff --git a/docs/sources/datasources/mssql/template-variables/index.md b/docs/sources/datasources/mssql/template-variables/index.md index cab7113d84b..162496ce75b 100644 --- a/docs/sources/datasources/mssql/template-variables/index.md +++ b/docs/sources/datasources/mssql/template-variables/index.md @@ -40,50 +40,60 @@ refs: # Microsoft SQL Server template variables Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables. -Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard. -Grafana refers to such variables as template variables. +Grafana displays these variables in drop-down select boxes at the top of the dashboard to help you change the data displayed in your dashboard. +Grafana refers to such variables as **template variables**. -For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables) documentation. +For general information on using variables in Grafana, refer to [Add variables](ref:add-template-variables). + +For an introduction to templating and template variables, refer to [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables). ## Query variable -If you add a template variable of the type `Query`, you can write a MS SQL query that can -return things like measurement names, key names or key values that are shown as a dropdown select box. +A query variable in Grafana dynamically retrieves values from your data source using a query. With a query variable, you can write a SQL query that returns values such as measurement names, key names, or key values that are shown in a drop-down select box. -For example, you can have a variable that contains all values for the `hostname` column in a table if you specify a query like this in the templating variable **Query** setting. +For example, the following query returns all values from the `hostname` column: ```sql SELECT hostname FROM host ``` -A query can return multiple columns and Grafana will automatically create a list from them. For example, the query below will return a list with values from `hostname` and `hostname2`. +A query can return multiple columns, and Grafana automatically generates a list using the values from those columns. For example, the following query returns values from both the `hostname` and `hostname2` columns, which are included in the variable's drop-down list. ```sql SELECT [host].[hostname], [other_host].[hostname2] FROM host JOIN other_host ON [host].[city] = [other_host].[city] ``` -Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allow you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value: +You can also create a key/value variable using a query that returns two columns named `__text` and `__value`. + +- The `__text` column defines the label shown in the drop-down. + +- The `__value` column defines the value passed to panel queries. + +This is useful when you want to display a user-friendly label (like a hostname) but use a different underlying value (like an ID). + +Note that the values in the `_text` column should be unique. If there are duplicates, Grafana uses only the first matching entry. ```sql SELECT hostname __text, id __value FROM host ``` -You can also create nested variables. For example, if you had another variable named `region`. Then you could have -the hosts variable only show hosts from the current selected region with a query like this (if `region` is a multi-value variable, then use the `IN` comparison operator rather than `=` to match against multiple values): +You can also create nested variables, where one variable depends on the value of another. For example, if you have a variable named `region`, you can configure a `hosts` variable to only show hosts from the selected region. If `region` is a multi-value variable, use the `IN` operator instead of `=` to match against multiple selected values. ```sql SELECT hostname FROM host WHERE region IN ($region) ``` -## Using variables in queries +## Use variables in queries -> Template variable values are only quoted when the template variable is a `multi-value`. +Grafana automatically quotes template variable values only when the template variable is a `multi-value`. -If the variable is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values. +When using a multi-value variable, use the `IN` comparison operator instead of `=` to match against multiple values. -There are two syntaxes: +Grafana supports two syntaxes for using variables in queries: -`$` Example with a template variable named `hostname`: +- **`$` syntax** + +Example with a template variable named `hostname`: ```sql SELECT @@ -94,7 +104,9 @@ WHERE $__timeFilter(atimestamp) and hostname in($hostname) ORDER BY atimestamp ``` -`[[varname]]` Example with a template variable named `hostname`: +- **`[[varname]]` syntax** + +Example with a template variable named `hostname`: ```sql SELECT @@ -105,10 +117,14 @@ WHERE $__timeFilter(atimestamp) and hostname in([[hostname]]) ORDER BY atimestamp ``` -### Disabling Quoting for Multi-value Variables +### Disable quoting for multi-value variables -Grafana automatically creates a quoted, comma-separated string for multi-value variables. For example: if `server01` and `server02` are selected then it will be formatted as: `'server01', 'server02'`. To disable quoting, use the csv formatting option for variables: +By default, Grafana formats multi-value variables as a quoted, comma-separated string. For example, if `server01` and `server02` are selected, the result will be `'server01'`, `'server02'`. To disable quoting, use the `csv` formatting option for variables: -`${servers:csv}` +```text +${servers:csv} +``` -Read more about variable formatting options in the [Variables](ref:variable-syntax-advanced-variable-format-options) documentation. +This outputs the values as an unquoted comma-separated list. + +Refer to [Advanced variable format options](ref:variable-syntax-advanced-variable-format-options) for additional information. diff --git a/docs/sources/datasources/mysql/_index.md b/docs/sources/datasources/mysql/_index.md index 33067e51983..f1152a4a762 100644 --- a/docs/sources/datasources/mysql/_index.md +++ b/docs/sources/datasources/mysql/_index.md @@ -2,10 +2,11 @@ aliases: - ../data-sources/mysql/ - ../features/datasources/mysql/ -description: introduction to the MySQL data source in Grafana +description: Introduction to the MySQL data source in Grafana keywords: - grafana - mysql + - data source - guide labels: products: @@ -45,7 +46,7 @@ refs: # MySQL data source -Grafana ships with a built-in MySQL data source plugin that allows you to query and visualize data from a MySQL-compatible database like MariaDB or Percona Server. You don't need to install a plugin in order to add the MySQL data source to your Grafana instance. +Grafana ships with a built-in MySQL data source plugin that allows you to query and visualize data from a MySQL-compatible database like [MariaDB](https://mariadb.org/) or [Percona Server](https://www.percona.com/). You don't need to install a plugin in order to add the MySQL data source to your Grafana instance. Grafana offers several configuration options for this data source as well as a visual and code-based query editor. diff --git a/docs/sources/datasources/mysql/configuration/_index.md b/docs/sources/datasources/mysql/configuration/_index.md index d98aff95019..424345476ad 100644 --- a/docs/sources/datasources/mysql/configuration/_index.md +++ b/docs/sources/datasources/mysql/configuration/_index.md @@ -45,10 +45,14 @@ This document provides instructions for configuring the MySQL data source and ex You must have the `Organization administrator` role in order to configure the MySQL data source. Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning system. -Grafana ships with the MySQL plugin, so no additional installation is required. - {{< admonition type="note" >}} -When adding a data source, ensure the database user you specify has only `SELECT` permissions on the relevant database and tables. Grafana does not validate the safety of queries, which means they can include potentially harmful SQL statements, such as `USE otherdb;` or `DROP TABLE user;`, which could get executed. To minimize this risk, Grafana strongly recommends creating a dedicated MySQL user with restricted permissions. +Grafana ships with the MySQL data source by default, so no additional installation is required. +{{< /admonition >}} + +{{< admonition type="caution" >}} +When adding a data source, ensure the database user you specify has only `SELECT` permissions on the relevant database and tables. Grafana does not validate the safety of queries, which means they can include potentially harmful SQL statements, such as `USE otherdb;` or `DROP TABLE user;`, which could get executed. + +To minimize this risk, Grafana strongly recommends creating a dedicated MySQL user with restricted permissions. {{< /admonition >}} Example: @@ -81,7 +85,7 @@ Following is a list of MySQL configuration options: **Connection:** -- **Host URL** - Enter the IP address/hostname and optional port of your MySQL instance. If the port is omitted the default 3306 port will be used. +- **Host URL** - Enter the IP address/hostname and optional port of your MySQL instance. If the port is omitted the default `3306` port will be used. - **Database** - Enter the name of your MySQL database. **Authentication:** diff --git a/docs/sources/datasources/mysql/query-editor/_index.md b/docs/sources/datasources/mysql/query-editor/_index.md index 8ec12f2789e..b34bcf65935 100644 --- a/docs/sources/datasources/mysql/query-editor/_index.md +++ b/docs/sources/datasources/mysql/query-editor/_index.md @@ -58,6 +58,9 @@ refs: destination: /docs/grafana//alerting/alerting-rules/templates/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/ + configure-standard-options: + - pattern: /docs/grafana/ + - destination: /docs/grafana//panels-visualizations/configure-standard-options/ --- # MySQL query editor @@ -125,26 +128,26 @@ Changes made to a query in Code mode will not transfer to Builder mode and will You can add macros to your queries to simplify the syntax and enable dynamic elements, such as date range filters. -| Macro example | Description | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `$__time(dateColumn)` | Replaces the value with an expression to convert to a UNIX timestamp and renames the column to `time_sec`. Example: _UNIX_TIMESTAMP(dateColumn) AS time_sec_. | -| `$__timeEpoch(dateColumn)` | Replaces the value with an expression to convert to a UNIX Epoch timestamp and renames the column to `time_sec`. Example: _UNIX_TIMESTAMP(dateColumn) AS time_sec_. | -| `$__timeFilter(dateColumn)` | Replaces the value a time range filter using the specified column name. Example: _dateColumn BETWEEN FROM_UNIXTIME(1494410783) AND FROM_UNIXTIME(1494410983)_ | -| `$__timeFrom()` | Replaces the value with the start of the currently active time selection. Example: _FROM_UNIXTIME(1494410783)_ | -| `$__timeTo()` | Replaces the value with the end of the currently active time selection. Example: _FROM_UNIXTIME(1494410983)_ | -| `$__timeGroup(dateColumn,'5m')` | Replaces the value with an expression suitable for use in a GROUP BY clause. Example: *cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) as signed)*300 as signed),\* | -| `$__timeGroup(dateColumn,'5m', 0)` | Same as the `$__timeGroup(dateColumn,'5m')` macro, but includes a fill parameter to ensure missing points in the series are added by Grafana, using 0 as the default value. **This applies only to time series queries.** | -| `$__timeGroup(dateColumn,'5m', NULL)` | Same as the `$__timeGroup(dateColumn,'5m', 0)` but NULL is used as the value for missing points. **This applies only to time series queries.** | -| `$__timeGroup(dateColumn,'5m', previous)` | Same as the `$__timeGroup(dateColumn,'5m', previous)` macro, but uses the previous value in the series as the fill value. If no previous value exists,`NULL` will be used. **This applies only to time series queries.** | -| `$__timeGroupAlias(dateColumn,'5m')` | Replaces the value identical to $\_\_timeGroup but with an added column alias. | -| `$__unixEpochFilter(dateColumn)` | Replaces the value by a time range filter using the specified column name with times represented as a UNIX timestamp. Example: _dateColumn > 1494410783 AND dateColumn < 1494497183_ | -| `$__unixEpochFrom()` | Replaces the value with the start of the currently active time selection as a UNIX timestamp. Example: _1494410783_ | -| `$__unixEpochTo()` | Replaces the value with the end of the currently active time selection as UNIX timestamp. Example: _1494497183_ | -| `$__unixEpochNanoFilter(dateColumn)` | Replaces the value with a time range filter using the specified column name with time represented as a nanosecond timestamp. Example: _dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872_ | -| `$__unixEpochNanoFrom()` | Replaces the value with the start of the currently active time selection as nanosecond timestamp. Example: _1494410783152415214_ | -| `$__unixEpochNanoTo()` | Replaces the value with the end of the currently active time selection as nanosecond timestamp. Example: _1494497183142514872_ | -| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but for times stored as Unix timestamp. **Note that `fillMode` only works with time series queries.** | -| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but also adds a column alias. **Note that `fillMode` only works with time series queries.** | +| Macro example | Description | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `$__time(dateColumn)` | Replaces the value with an expression to convert to a UNIX timestamp and renames the column to `time_sec`. It also helps to recognize the `time` column, as required in Time Series format. Example: _UNIX_TIMESTAMP(dateColumn) AS time_sec_. | +| `$__timeEpoch(dateColumn)` | Replaces the value with an expression to convert to a UNIX Epoch timestamp and renames the column to `time_sec`. Example: _UNIX_TIMESTAMP(dateColumn) AS time_sec_. | +| `$__timeFilter(dateColumn)` | Applies a time range filter using the specified column name and fetches only the data that falls within that range. Example: _dateColumn BETWEEN FROM_UNIXTIME(1494410783) AND FROM_UNIXTIME(1494410983)_ | +| `$__timeFrom()` | Replaces the value with the start of the currently active time selection. Example: _FROM_UNIXTIME(1494410783)_ | +| `$__timeTo()` | Replaces the value with the end of the currently active time selection. Example: _FROM_UNIXTIME(1494410983)_ | +| `$__timeGroup(dateColumn,'5m')` | Replaces the value with an expression suitable for use in a GROUP BY clause and creates the bucket timestamps at a fixed interval. Example: *cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) as signed)*300 as signed),\* | +| `$__timeGroup(dateColumn,'5m', 0)` | Same as the `$__timeGroup(dateColumn,'5m')` macro, but includes a fill parameter to ensure missing points in the series are added by Grafana, using 0 as the default value. **This applies only to time series queries.** | +| `$__timeGroup(dateColumn,'5m', NULL)` | Same as the `$__timeGroup(dateColumn,'5m', 0)` but NULL is used as the value for missing points. **This applies only to time series queries.** | +| `$__timeGroup(dateColumn,'5m', previous)` | Same as the `$__timeGroup(dateColumn,'5m', previous)` macro, but uses the previous value in the series as the fill value. If no previous value exists,`NULL` will be used. **This applies only to time series queries.** | +| `$__timeGroupAlias(dateColumn,'5m')` | Replaces the value identical to $\_\_timeGroup but with an added column alias. | +| `$__unixEpochFilter(dateColumn)` | Replaces the value by a time range filter using the specified column name with times represented as a UNIX timestamp. Example: _dateColumn > 1494410783 AND dateColumn < 1494497183_ | +| `$__unixEpochFrom()` | Replaces the value with the start of the currently active time selection as a UNIX timestamp. Example: _1494410783_ | +| `$__unixEpochTo()` | Replaces the value with the end of the currently active time selection as UNIX timestamp. Example: _1494497183_ | +| `$__unixEpochNanoFilter(dateColumn)` | Replaces the value with a time range filter using the specified column name with time represented as a nanosecond timestamp. Example: _dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872_ | +| `$__unixEpochNanoFrom()` | Replaces the value with the start of the currently active time selection as nanosecond timestamp. Example: _1494410783152415214_ | +| `$__unixEpochNanoTo()` | Replaces the value with the end of the currently active time selection as nanosecond timestamp. Example: _1494497183142514872_ | +| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but for times stored as Unix timestamp. **Note that `fillMode` only works with time series queries.** | +| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but also adds a column alias. **Note that `fillMode` only works with time series queries.** | ## Table SQL queries @@ -180,106 +183,127 @@ The examples in this section refer to the data in the following table: +---------------------+--------------+---------------------+----------+ | time_date_time | value_double | CreatedAt | hostname | +---------------------+--------------+---------------------+----------+ -| 2020-01-02 03:05:00 | 3.0 | 2020-01-02 03:05:00 | 10.0.1.1 | -| 2020-01-02 03:06:00 | 4.0 | 2020-01-02 03:06:00 | 10.0.1.2 | -| 2020-01-02 03:10:00 | 6.0 | 2020-01-02 03:10:00 | 10.0.1.1 | -| 2020-01-02 03:11:00 | 7.0 | 2020-01-02 03:11:00 | 10.0.1.2 | -| 2020-01-02 03:20:00 | 5.0 | 2020-01-02 03:20:00 | 10.0.1.2 | +| 2025-01-02 03:05:00 | 3.0 | 2025-01-02 03:05:00 | 10.0.1.1 | +| 2025-01-02 03:06:00 | 4.0 | 2025-01-02 03:06:00 | 10.0.1.2 | +| 2025-01-02 03:10:00 | 6.0 | 2025-01-02 03:10:00 | 10.0.1.1 | +| 2025-01-02 03:11:00 | 7.0 | 2025-01-02 03:11:00 | 10.0.1.2 | +| 2025-01-02 03:20:00 | 5.0 | 2025-01-02 03:20:00 | 10.0.1.2 | +---------------------+--------------+---------------------+----------+ ``` -A time series query result is returned in a [wide data frame format](https://grafana.com/developers/plugin-tools/key-concepts/data-frames#wide-format). Any column except time or of type string transforms into value fields in the data frame query result. Any string column transforms into field labels in the data frame query result. - {{< admonition type="note" >}} For backward compatibility, an exception to the aforementioned rule applies to queries returning three columns, including a string column named `metric`. Instead of converting the metric column into field labels, it is used as the field name, and the series name is set to the value of the metric column. Refer to the following example with a metric column. {{< /admonition >}} -**Example with `metric` column:** +**Example with `$__time(dateColumn)` Macro:** + +```sql +SELECT + $__time(time_date_time), + value_double +FROM my_data +ORDER BY time_date_time +``` + +Table panel result: + +{{< figure alt="output of time macro" src="/media/docs/grafana/data-sources/mysql/screenshot-time-and-timefilter-macro.png" >}} + +In the following example, the result includes two columns, `Time` and `value_double`, which represent the data associated with fixed timestamps. This query does not apply a time range filter and returns all rows from the table. + +**Example with `$__timeFilter(dateColumn)` Macro:** + +```sql +SELECT + $__time(time_date_time), + value_double +FROM my_data +WHERE $__timeFilter(time_date_time) +ORDER BY time_date_time +``` + +Table panel result: + +{{< figure alt="output of time filter macro" src="/media/docs/grafana/data-sources/mysql/screenshot-time-and-timefilter-macro.png" >}} + +This example returns the same result as the previous one, but adds support for filtering data using the Grafana time picker. + +**Example with `$__timeGroup(dateColumn,'5m')` Macro:** + +```sql +SELECT + $__timeGroup(time_date_time, '5m') AS time, + sum(value_double) AS sum_value +FROM my_data +WHERE $__timeFilter(time_date_time) +GROUP BY time +ORDER BY time +``` + +Table panel result: + +{{< figure alt="output of time group macro" src="/media/docs/grafana/data-sources/mysql/screenshot-timegroup-macro.png" >}} + +Given the result in the following example, the data is grouped and aggregated within buckets with timestamps of fixed interval i.e. 5 mins. To customize the default series name formatting (optional), refer to [Standard options definitions](ref:configure-standard-options). + +**Example with `$__timeGroupAlias(dateColumn,'5m')` Macro:** ```sql SELECT $__timeGroupAlias(time_date_time,'5m'), min(value_double), 'min' as metric -FROM test_data +FROM my_data WHERE $__timeFilter(time_date_time) GROUP BY time ORDER BY time ``` -Data frame result: +Table panel result: -```text -+---------------------+-----------------+ -| Name: time | Name: min | -| Labels: | Labels: | -| Type: []time.Time | Type: []float64 | -+---------------------+-----------------+ -| 2020-01-02 03:05:00 | 3 | -| 2020-01-02 03:10:00 | 6 | -| 2020-01-02 03:20:00 | 5 | -+---------------------+-----------------+ -``` +{{< figure alt="output of time group alias macro" src="/media/docs/grafana/data-sources/mysql/screenshot-timeGroupAlias-macro.png" >}} -To customize the default series name formatting (optional), refer to [Standard options definitions](ref:configure-standard-options-display-name). +The following result is similar to the result of the `$__timeGroup(dateColumn,'5m')` macro, except it uses a built-in alias for the time column. +To customize the default series name formatting (optional), refer to [Standard options definitions](ref:configure-standard-options). -**Example using the fill parameter in the $\_\_timeGroupAlias macro to convert null values to be zero instead:** +**Example with `$__timeGroupAlias` Macro to convert null values to zero instead:** ```sql SELECT $__timeGroupAlias(createdAt,'5m',0), sum(value_double) as value, hostname -FROM test_data +FROM my_data WHERE $__timeFilter(createdAt) GROUP BY time, hostname ORDER BY time ``` -Given the data frame result in the following example and using the graph panel, you will get two series named _value 10.0.1.1_ and _value 10.0.1.2_. To render the series with a name of _10.0.1.1_ and _10.0.1.2_ , use a [Standard options definitions](ref:configure-standard-options-display-name) display value of `${__field.labels.hostname}`. +Table panel result: -Data frame result: +{{< figure alt="output of null values to zero case, for time group alias macro" src="/media/docs/grafana/data-sources/mysql/screenshot-timeGroupAlias-macro-conv-null-to-zero.png" >}} -```text -+---------------------+---------------------------+---------------------------+ -| Name: time | Name: value | Name: value | -| Labels: | Labels: hostname=10.0.1.1 | Labels: hostname=10.0.1.2 | -| Type: []time.Time | Type: []float64 | Type: []float64 | -+---------------------+---------------------------+---------------------------+ -| 2020-01-02 03:05:00 | 3 | 4 | -| 2020-01-02 03:10:00 | 6 | 7 | -| 2020-01-02 03:15:00 | 0 | 0 | -| 2020-01-02 03:20:00 | 0 | 5 | -+---------------------+---------------------------+---------------------------+ -``` +Given the result in the following example, null values within bucket timestamps are replaced by zero and also add the `Time` column alias by default. To customize the default series name formatting (optional), refer to [Standard options definitions](ref:configure-standard-options) to display the value of `${__field.labels.hostname}`. -**Example with multiple columns:** +**Example with multiple columns for `$__timeGroupAlias(dateColumn,'5m')` Macro:** ```sql SELECT $__timeGroupAlias(time_date_time,'5m'), min(value_double) as min_value, max(value_double) as max_value -FROM test_data +FROM my_data WHERE $__timeFilter(time_date_time) GROUP BY time ORDER BY time ``` -Data frame result: +Table panel result: -```text -+---------------------+-----------------+-----------------+ -| Name: time | Name: min_value | Name: max_value | -| Labels: | Labels: | Labels: | -| Type: []time.Time | Type: []float64 | Type: []float64 | -+---------------------+-----------------+-----------------+ -| 2020-01-02 03:05:00 | 3 | 4 | -| 2020-01-02 03:10:00 | 6 | 7 | -| 2020-01-02 03:20:00 | 5 | 5 | -+---------------------+-----------------+-----------------+ -``` +{{< figure alt="output with multiple colummns for time group alias macro" src="/media/docs/grafana/data-sources/mysql/screenshot-timeGroupAlias-macro-multiple-columns.png" >}} + +The query returns multiple columns representing minimum and maximum values within the defined range. ## Templating @@ -392,6 +416,21 @@ WHERE $__unixEpochFilter(epoch_time) ``` +You may use one or more tags to show them as annotations in a common-separate string. + +**Example query using a `time` column with epoch values for a single tag:** + +```sql +SELECT + epoch_time as time, + metric1 as text, + tag1 as tag +FROM + my_data +WHERE + $__unixEpochFilter(epoch_time) +``` + **Example region query using `time` and `timeend` columns with epoch values:** ```sql diff --git a/docs/sources/developers/http_api/dashboard.md b/docs/sources/developers/http_api/dashboard.md index b862fb7c4a6..f5456a22e77 100644 --- a/docs/sources/developers/http_api/dashboard.md +++ b/docs/sources/developers/http_api/dashboard.md @@ -568,8 +568,6 @@ Gets a dashboard via the dashboard uid. - namespace: to read more about the namespace to use, see the [API overview](https://grafana.com/docs/grafana//developers/http_api/apis/). - uid: the unique identifier of the dashboard to update. this will be the _name_ in the dashboard response -Note: For large dashboards, add `/dto` to the end of the URL to get the full dashboard body. - **Required permissions** See note in the [introduction]({{< ref "#dashboard-api" >}}) for an explanation. diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 3993809d076..ef8884f3c55 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -19,7 +19,7 @@ To learn more about Grafana Enterprise, refer to [our product page](/enterprise) ## Enterprise features in Grafana Cloud -Many Grafana Enterprise features are also available in [Grafana Cloud](/docs/grafana-cloud) Free, Pro, and Advanced accounts. For details, refer to [Grafana Cloud pricing](/pricing/#featuresTable). +Many Grafana Enterprise features are also available in paid [Grafana Cloud](/docs/grafana-cloud) accounts. For details, refer to [Grafana Cloud features](/docs/grafana-cloud/introduction/understand-grafana-cloud-features/). For pricing and plans, refer to [Grafana Cloud pricing](https://grafana.com/pricing/). To migrate to Grafana Cloud, refer to [Migrate from Grafana Enterprise to Grafana Cloud](/docs/grafana//administration/migration-guide/) diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md index 4be10911af6..a5f4712c502 100644 --- a/docs/sources/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md @@ -270,6 +270,14 @@ You can enable infinite panning in a canvas when pan and zoom is enabled. This a Infinite panning is an experimental feature that may not work as expected in all scenarios. For example, elements that are not top-left constrained may experience unexpected movement when panning. {{< /admonition >}} +### Tooltip options + +The **Tooltip mode** setting controls the display of tooltips when hovering over canvas elements that are connected to data, data links, or actions. +The options are: + +- **Enabled** - Show a tooltip when the cursor hovers over an element. +- **Disabled** - Tooltips are not shown on hover. + ### Layer options The **Layer** options let you add elements to the canvas and control its appearance: diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index 707acfababc..10f87a87953 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -236,11 +236,11 @@ If you want to apply a cell type to only some fields instead of all fields, you | Cell type | Description | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [Auto](#auto) | A basic text and number cell. | -| [Sparkline](#sparkline) | Shows values rendered as a sparkline. | | [Colored text](#colored-text) | If thresholds, value mappings, or color schemes are set, then the cell text is displayed in the appropriate color. | | [Colored background](#colored-background) | If thresholds, value mappings, or color schemes are set, then the cell background is displayed in the appropriate color. | -| [Gauge](#gauge) | Values are displayed as a horizontal bar gauge. You can set the [Gauge display mode](#gauge-display-mode) and the [Value display](#value-display) options. | | Data links | If you've configured data links, when the cell type is **Auto**, the cell text becomes clickable. If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly, use a **Cell option > Cell type > Data links** field override. | +| [Gauge](#gauge) | Values are displayed as a horizontal bar gauge. You can set the [Gauge display mode](#gauge-display-mode) and the [Value display](#value-display) options. | +| [Sparkline](#sparkline) | Shows values rendered as a sparkline. | | [JSON View](#json-view) | Shows values formatted as code. | | [Image](#image) | Displays an image when the value is a URL or a base64 encoded image. | | [Actions](#actions) | The cell displays a button that triggers a basic, unauthenticated API call when clicked. | @@ -254,32 +254,6 @@ It has the following cell options: {{< docs/shared lookup="visualizations/cell-options.md" source="grafana" version="" >}} -#### Sparkline - -This cell type shows values rendered as a sparkline. -To show sparklines on data with multiple time series, use the [Time series to table transformation](ref:time-series-to-table-transformation) to process it into a format the table can show. - -![Table using sparkline cell type](/media/docs/grafana/panels-visualizations/screenshot-table-as-sparkline-v11.3.png) - -The sparkline cell type options are described in the following table. -For more detailed information about all of the sparkline styling options (except **Hide value**), refer to the [time series graph styles documentation](ref:graph-styles). - - -| Option | Description | -| ------------------- | --------------------------------------------------------------------------------------------- | -| Hide value | Toggle the switch on or off to display or hide the cell value on the sparkline. | -| Style | Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. You can use overrides to combine multiple styles in the same graph. | -| Line interpolation | How the graph interpolates the series line. Choose from:
  • **Linear** - Points are joined by straight lines.
  • **Smooth** - Points are joined by curved lines that smooths transitions between points.
  • **Step before** - The line is displayed as steps between points. Points are rendered at the end of the step.
  • **Step after** - The line is displayed as steps between points. Points are rendered at the beginning of the step.
| -| Line width | The thickness of the series lines or the outline for bars using the **Line width** slider. | -| Fill opacity | The series area fill color using the **Fill opacity** slider. | -| Gradient mode | Gradient mode controls the gradient fill, which is based on the series color. Gradient appearance is influenced by the **Fill opacity** setting. To change the color, use the standard color scheme field option. For more information, refer to [Color scheme](ref:color-scheme). Choose from:
  • **None** - No gradient fill. This is the default setting.
  • **Opacity** - An opacity gradient where the opacity of the fill increases as y-axis values increase.
  • **Hue** - A subtle gradient that's based on the hue of the series color.
| -| Line style | Choose from:
  • **Solid**
  • **Dash** - Select the length and gap for the line dashes. Default dash spacing is 10, 10.
  • **Dots** - Select the gap for the dot spacing. Default dot spacing is 0, 10.
| -| Connect null values | How null values, which are gaps in the data, appear on the graph. Null values can be connected to form a continuous line or set to a threshold above which gaps in the data are no longer connected. Choose from:
  • **Never** - Time series data points with gaps in the data are never connected.
  • **Always** - Time series data points with gaps in the data are always connected.
  • **Threshold** - Specify a threshold above which gaps in the data are no longer connected. This can be useful when the connected gaps in the data are of a known size or within a known range, and gaps outside this range should no longer be connected.
| -| Show points | Whether to show data points to lines or bars. Choose from:
  • **Auto** - Grafana determines a point's visibility based on the density of the data. If the density is low, then points appear.
  • **Always** - Show the points regardless of how dense the dataset is.
  • **Never** - Don't show points.
| -| Point size | Set the size of the points, from 1 to 40 pixels in diameter. | -| Bar alignment | Set the position of the bar relative to a data point. | - - #### Colored text If thresholds, value mappings, or color schemes are set, the cell text is displayed in the appropriate color. @@ -346,6 +320,32 @@ Labels displayed alongside of the gauges can be set to be colored by value, matc | Hidden | Labels are hidden. {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-labels-hidden-v11.3.png" alt="Table with labels hidden" >}} | +#### Sparkline + +This cell type shows values rendered as a sparkline. +To show sparklines on data with multiple time series, use the [Time series to table transformation](ref:time-series-to-table-transformation) to process it into a format the table can show. + +![Table using sparkline cell type](/media/docs/grafana/panels-visualizations/screenshot-table-as-sparkline-v11.3.png) + +The sparkline cell type options are described in the following table. +For more detailed information about all of the sparkline styling options (except **Hide value**), refer to the [time series graph styles documentation](ref:graph-styles). + + +| Option | Description | +| ------------------- | --------------------------------------------------------------------------------------------- | +| Hide value | Toggle the switch on or off to display or hide the cell value on the sparkline. | +| Style | Choose whether to display your time-series data as **Lines**, **Bars**, or **Points**. You can use overrides to combine multiple styles in the same graph. | +| Line interpolation | How the graph interpolates the series line. Choose from:
  • **Linear** - Points are joined by straight lines.
  • **Smooth** - Points are joined by curved lines that smooths transitions between points.
  • **Step before** - The line is displayed as steps between points. Points are rendered at the end of the step.
  • **Step after** - The line is displayed as steps between points. Points are rendered at the beginning of the step.
| +| Line width | The thickness of the series lines or the outline for bars using the **Line width** slider. | +| Fill opacity | The series area fill color using the **Fill opacity** slider. | +| Gradient mode | Gradient mode controls the gradient fill, which is based on the series color. Gradient appearance is influenced by the **Fill opacity** setting. To change the color, use the standard color scheme field option. For more information, refer to [Color scheme](ref:color-scheme). Choose from:
  • **None** - No gradient fill. This is the default setting.
  • **Opacity** - An opacity gradient where the opacity of the fill increases as y-axis values increase.
  • **Hue** - A subtle gradient that's based on the hue of the series color.
| +| Line style | Choose from:
  • **Solid**
  • **Dash** - Select the length and gap for the line dashes. Default dash spacing is 10, 10.
  • **Dots** - Select the gap for the dot spacing. Default dot spacing is 0, 10.
| +| Connect null values | How null values, which are gaps in the data, appear on the graph. Null values can be connected to form a continuous line or set to a threshold above which gaps in the data are no longer connected. Choose from:
  • **Never** - Time series data points with gaps in the data are never connected.
  • **Always** - Time series data points with gaps in the data are always connected.
  • **Threshold** - Specify a threshold above which gaps in the data are no longer connected. This can be useful when the connected gaps in the data are of a known size or within a known range, and gaps outside this range should no longer be connected.
| +| Show points | Whether to show data points to lines or bars. Choose from:
  • **Auto** - Grafana determines a point's visibility based on the density of the data. If the density is low, then points appear.
  • **Always** - Show the points regardless of how dense the dataset is.
  • **Never** - Don't show points.
| +| Point size | Set the size of the points, from 1 to 40 pixels in diameter. | +| Bar alignment | Set the position of the bar relative to a data point. | + + #### JSON View This cell type shows values formatted as code. diff --git a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md index 2fd80cbac5f..92004450844 100644 --- a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md +++ b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md @@ -15,10 +15,7 @@ weight: 300 Custom branding enables you to replace the Grafana Labs brand and logo with your corporate brand and logo. {{< admonition type="note" >}} -Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud). For Cloud Advanced and Enterprise customers, please provide custom elements and logos to our Support team. We will help you host your images and update your custom branding. - -This feature is not available for Grafana Free and Pro tiers. -For more information on feature availability across plans, refer to our [feature comparison page](/docs/grafana-cloud/cost-management-and-billing/understand-grafana-cloud-features/) +Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. For Cloud customers, please provide custom elements and logos to our Support team. We will help you host your images and update your custom branding. {{< /admonition >}} diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md index 3d1c4a48a6b..a379eb82d52 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/auth-proxy/index.md @@ -237,7 +237,8 @@ If the user is deleted from Grafana, the user will be not be able to login and r ### Team Sync {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. + {{< /admonition >}} With Team Sync, it's possible to set up synchronization between teams in your authentication provider and Grafana. You can send Grafana values as part of an HTTP header and have Grafana map them to your team structure. This allows you to put users into specific teams automatically. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index 69e6e1415f0..f3d2c5bea74 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -414,7 +414,7 @@ auto_login = true ### Team Sync {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} With Team Sync you can map your Entra ID groups to teams in Grafana so that your users will automatically be added to diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index 9ebe70e6e55..f28dc35b7f6 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -320,7 +320,7 @@ org_mapping = org_foo:org_foo:Viewer org_bar:org_bar:Editor *:org_baz:Editor ## Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} By using Team Sync, you can link your OAuth2 groups to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md index 7a327576cfd..c11ef4fd3ab 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md @@ -211,7 +211,7 @@ role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer' ## Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md index 22363e59b54..ac45c634599 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md @@ -236,7 +236,7 @@ use_refresh_token = true ## Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md index 59f2c6951a8..367d05c068d 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md @@ -160,7 +160,7 @@ auto_login = true ### Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md index 88adc9d4a2a..6425eb09db7 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md @@ -109,7 +109,7 @@ viewer ## Team sync {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} [Teamsync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync/) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md index 8166760890f..53618823a06 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md @@ -236,7 +236,7 @@ org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"] ### Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-team-role-mapping/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-team-role-mapping/_index.md index edfedcefb9a..ad4dab8cf00 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-team-role-mapping/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-team-role-mapping/_index.md @@ -12,7 +12,7 @@ weight: 540 # Configure team sync for SAML {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} To use SAML Team sync, set [`assertion_attribute_groups`](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/enterprise-configuration/#assertion_attribute_groups) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/saml-ui/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/saml-ui/_index.md index 52d2c84fbfd..dee23439664 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/saml-ui/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/saml-ui/_index.md @@ -14,7 +14,7 @@ weight: 510 # Configure SAML authentication using the Grafana user interface {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) version 10.0 and later, and [Grafana Cloud Pro or Advanced](https://grafana.com/docs/grafana//introduction/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) version 10.0 and later, and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} You can configure SAML authentication in Grafana through the user interface (UI) or the Grafana configuration file. For instructions on how to set up SAML using the Grafana configuration file, refer to [Configure SAML authentication using the configuration file](../#configure-saml-using-the-grafana-config-file). @@ -40,7 +40,7 @@ To follow this guide, you need: These permissions are granted by `fixed:authentication.config:writer` role. By default, this role is granted to Grafana server administrator in self-hosted instances and to Organization admins in Grafana Cloud instances. -- Grafana instance running Grafana version 10.0 or later with [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) or [Grafana Cloud Pro or Advanced](https://grafana.com/docs/grafana//introduction/grafana-cloud/) license. +- Grafana instance running Grafana version 10.0 or later with [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. ## Steps To Configure SAML Authentication diff --git a/docs/sources/setup-grafana/configure-security/configure-request-security.md b/docs/sources/setup-grafana/configure-security/configure-request-security.md index 8dca935e928..8164799a6d8 100644 --- a/docs/sources/setup-grafana/configure-security/configure-request-security.md +++ b/docs/sources/setup-grafana/configure-security/configure-request-security.md @@ -19,7 +19,7 @@ Request security allows you to limit requests from the Grafana server by targeti This can be used to limit access to internal systems that the server Grafana runs on can access but that users of Grafana should not be able to access. This feature does not affect traffic from the Grafana users browser. {{< admonition type="note" >}} -Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/). +Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and to users on select Grafana Cloud account plans. For pricing information, visit our [pricing page](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} {{< admonition type="note" >}} diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md index d3c2496f75b..f8e2ee47611 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/_index.md @@ -20,7 +20,7 @@ weight: 300 System for Cross-domain Identity Management (SCIM) is an open standard that allows automated user provisioning and management. With SCIM, you can automate the provisioning of users and groups from your identity provider to Grafana. {{< admonition type="note" >}} -Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/) in [public preview](https://grafana.com/docs/release-life-cycle/). +Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and select Grafana Cloud plans in [public preview](https://grafana.com/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. {{< /admonition >}} diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md index 1554484f45b..bc04f80fa52 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-azuread/_index.md @@ -21,7 +21,7 @@ weight: 320 # Configure SCIM with Azure AD {{< admonition type="note" >}} -Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/). +Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} {{< admonition type="warning" >}} @@ -49,7 +49,7 @@ Refer to the [SAML authentication with Azure AD documentation](../../configure-a Before configuring SCIM with Azure AD, ensure you have: -- Grafana Enterprise or Grafana Cloud Advanced +- Grafana Enterprise or a paid Grafana Cloud account with SCIM provisioning enabled. - Admin access to both Grafana and Azure AD - SCIM feature enabled in Grafana diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md index 1555ae72754..50434391f7e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/configure-scim-with-okta/_index.md @@ -19,7 +19,7 @@ weight: 320 # Configure SCIM with Okta {{< admonition type="note" >}} -Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/). +Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} {{< admonition type="warning" >}} @@ -39,7 +39,7 @@ For more information, refer to the [feature toggles documentation](/docs/grafana Before configuring SCIM with Okta, ensure you have: -- Grafana Enterprise or Grafana Cloud Advanced +- Grafana Enterprise or a paid Grafana Cloud account with SCIM provisioning enabled. - Admin access to both Grafana and Okta - [SAML authentication configured with Okta](../../configure-authentication/saml/configure-saml-with-okta/) - SCIM feature enabled in Grafana diff --git a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md index 2b3b0524884..ed9592e5929 100644 --- a/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-scim-provisioning/manage-users-teams/_index.md @@ -18,7 +18,7 @@ weight: 310 # Manage users and teams with SCIM {{< admonition type="note" >}} -Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/). +Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} {{< admonition type="warning" >}} diff --git a/docs/sources/setup-grafana/configure-security/export-logs.md b/docs/sources/setup-grafana/configure-security/export-logs.md index 7bb7b453fc7..f55336f8a67 100644 --- a/docs/sources/setup-grafana/configure-security/export-logs.md +++ b/docs/sources/setup-grafana/configure-security/export-logs.md @@ -18,7 +18,7 @@ weight: 900 # Export logs of usage insights {{< admonition type="note" >}} -Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and [Grafana Cloud Pro and Advanced](/docs/grafana-cloud/). +Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} By exporting usage logs to Loki, you can directly query them and create dashboards of the information that matters to you most, such as dashboard errors, most active organizations, or your top-10 most-used queries. This configuration is done for you in Grafana Cloud, with provisioned dashboards. Read about them in the [Grafana Cloud documentation](/docs/grafana-cloud/usage-insights/). diff --git a/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md b/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md index 34d3ccd8dcb..e4a44e54ed6 100644 --- a/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md +++ b/docs/sources/setup-grafana/configure-security/planning-iam-strategy/index.md @@ -182,7 +182,7 @@ When connecting Grafana to an identity provider, it's important to think beyond Team sync is a feature that allows you to synchronize teams or groups from your authentication provider with teams in Grafana. This means that users of specific teams or groups in LDAP, OAuth, or SAML will be automatically added or removed as members of corresponding teams in Grafana. Whenever a user logs in, Grafana will check for any changes in the teams or groups of the authentication provider and update the user's teams in Grafana accordingly. This makes it easy to manage user permissions across multiple systems. {{< admonition type="note" >}} -Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and [Grafana Cloud Advanced](/docs/grafana-cloud/). +Available in [Grafana Enterprise](../../../introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. {{< /admonition >}} {{< admonition type="note" >}} diff --git a/docs/variables.mk b/docs/variables.mk index a3302041ce5..1a8c9cb6691 100644 --- a/docs/variables.mk +++ b/docs/variables.mk @@ -1,11 +1,7 @@ # List of projects to provide to the make-docs script. -PROJECTS := grafana - -# Use the doc-validator image defined in CI by default. -export DOC_VALIDATOR_IMAGE := $(shell sed -En 's, *image: "(grafana/doc-validator[^"]+)",\1,p' "$(shell git rev-parse --show-toplevel)/.github/workflows/doc-validator.yml") - -# Skip some doc-validator checks. -export DOC_VALIDATOR_SKIP_CHECKS := $(shell sed -En "s, *'--skip-checks=(.+)',\1,p" "$(shell git rev-parse --show-toplevel)/.github/workflows/doc-validator.yml") - -# Only run on sections that have been enabled in CI. -export DOC_VALIDATOR_INCLUDE := $(shell sed -En "s, *'--include=\\^docs/sources/(.+)',/hugo/content/docs/grafana/latest/\1,p" "$(shell git rev-parse --show-toplevel)/.github/workflows/doc-validator.yml") +# Format is PROJECT[:[VERSION][:[REPOSITORY][:[DIRECTORY]]]] +# The following PROJECTS value mounts content into the "grafana" project, at the "latest" version, which is the default if not explicitly set. +# This results in the content being served at /docs/grafana/latest/. +# The source of the content is the current repository which is determined by the name of the parent directory of the git root. +# This overrides the default behavior of assuming the repository directory is the same as the project name. +PROJECTS := grafana::$(notdir $(basename $(shell git rev-parse --show-toplevel))) diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts index 12023c9de6c..9824d0d08df 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts @@ -14,7 +14,7 @@ test.use({ // these tests require a larger viewport test.use({ - viewport: { width: 1280, height: 1080 }, + viewport: { width: 1920, height: 1080 }, }); test.describe( @@ -124,37 +124,49 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow).click(); await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addPanel).last().click(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 2')) - ).toBeVisible(); + const firstRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row')); + await expect(firstRow).toBeVisible(); + await firstRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(5); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + const secondRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row 1')); + await expect(secondRow).toBeVisible(); + + await secondRow.scrollIntoViewIfNeeded(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(1); + + const thirdRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row 2')); + await expect(thirdRow).toBeVisible(); + + await thirdRow.scrollIntoViewIfNeeded(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: thirdRow }) + ).toHaveCount(1); // Save dashboard and reload await saveDashboard(dashboardPage, selectors); await page.reload(); + await expect(firstRow).toBeVisible(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 2')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + await expect(secondRow).toBeVisible(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(5); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(1); + + thirdRow.scrollIntoViewIfNeeded(); + await expect(thirdRow).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: thirdRow }) + ).toHaveCount(1); await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); @@ -172,15 +184,9 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.deleteButton).click(); await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeHidden(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 2')) - ).toBeHidden(); + await expect(firstRow).toBeVisible(); + await expect(secondRow).toBeHidden(); + await expect(thirdRow).toBeHidden(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); @@ -188,15 +194,9 @@ test.describe( await saveDashboard(dashboardPage, selectors); await page.reload(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeHidden(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 2')) - ).toBeHidden(); + await expect(firstRow).toBeVisible(); + await expect(secondRow).toBeHidden(); + await expect(thirdRow).toBeHidden(); await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); @@ -224,15 +224,21 @@ test.describe( .getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow) .scrollIntoViewIfNeeded(); + const firstRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row')); + await expect(firstRow).toBeVisible(); + + firstRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + const secondRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row 1')); + await expect(secondRow).toBeVisible(); + + secondRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(6); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); await saveDashboard(dashboardPage, selectors); await page.reload(); @@ -243,15 +249,18 @@ test.describe( .last() .scrollIntoViewIfNeeded(); + await expect(firstRow).toBeVisible(); + await expect(secondRow).toBeVisible(); + + firstRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + secondRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(6); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); }); test('can duplicate a row', async ({ dashboardPage, selectors, page }) => { @@ -274,15 +283,21 @@ test.describe( .getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow) .scrollIntoViewIfNeeded(); + const firstRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row')); + await expect(firstRow).toBeVisible(); + + firstRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + const secondRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row 1')); + await expect(secondRow).toBeVisible(); + + secondRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(6); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); await saveDashboard(dashboardPage, selectors); await page.reload(); @@ -293,15 +308,18 @@ test.describe( .last() .scrollIntoViewIfNeeded(); + await expect(firstRow).toBeVisible(); + await expect(secondRow).toBeVisible(); + + firstRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + secondRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(6); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); }); test('can collapse rows', async ({ dashboardPage, selectors, page }) => { @@ -324,26 +342,29 @@ test.describe( .getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow) .scrollIntoViewIfNeeded(); + const firstRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row')); + await expect(firstRow).toBeVisible(); + + firstRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + const secondRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New row 1')); + await expect(secondRow).toBeVisible(); + + secondRow.scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(6); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); // Collapse rows by clicking on their titles await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')).click(); await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')).click(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); + await expect(firstRow).toBeVisible(); + await expect(secondRow).toBeVisible(); + await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(0); @@ -351,12 +372,9 @@ test.describe( await saveDashboard(dashboardPage, selectors); await page.reload(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) - ).toBeVisible(); + await expect(firstRow).toBeVisible(); + await expect(secondRow).toBeVisible(); + await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(0); @@ -684,67 +702,75 @@ test.describe( // Select rows layout await page.getByLabel('Rows').click(); + await dashboardPage + .getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1')) + .scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New tab')) + dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1')) ).toBeVisible(); + await dashboardPage + .getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 2')) + .scrollIntoViewIfNeeded(); + + const firstRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab')); + const secondRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1')); + const thirdRow = dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 2')); + + firstRow.scrollIntoViewIfNeeded(); + await expect(firstRow).toBeVisible(); // Wait for panels to load await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first() ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 1')) - .scrollIntoViewIfNeeded(); + await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 1')) - ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 2')) - .scrollIntoViewIfNeeded(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + secondRow.scrollIntoViewIfNeeded(); + await expect(secondRow).toBeVisible(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 2')) - ).toBeVisible(); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); + + thirdRow.scrollIntoViewIfNeeded(); + await expect(thirdRow).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: thirdRow }) + ).toHaveCount(3); // scroll `New row` into view - this is at the bottom of the dashboard body await dashboardPage .getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow) .scrollIntoViewIfNeeded(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(9); + await expect(dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow)).toBeVisible(); await saveDashboard(dashboardPage, selectors); await page.reload(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New tab')) - ).toBeVisible(); + await expect(firstRow).toBeVisible(); + // Wait for panels to load await expect( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first() ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 1')) - .scrollIntoViewIfNeeded(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 1')) - ).toBeVisible(); - await dashboardPage - .getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 2')) - .scrollIntoViewIfNeeded(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New tab 2')) - ).toBeVisible(); - - // scroll last `New panel` into view - this is at the bottom of the dashboard body - await dashboardPage - .getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - .last() - .scrollIntoViewIfNeeded(); await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) - ).toHaveCount(9); + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: firstRow }) + ).toHaveCount(3); + + secondRow.scrollIntoViewIfNeeded(); + await expect(secondRow).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: secondRow }) + ).toHaveCount(3); + + thirdRow.scrollIntoViewIfNeeded(); + await expect(thirdRow).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'), { root: thirdRow }) + ).toHaveCount(3); }); test('can group and ungroup new panels into tab with row', async ({ dashboardPage, selectors, page }) => { diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts index 6c55988a309..2e6b80b7b3c 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts @@ -10,6 +10,10 @@ test.use({ }, }); +test.use({ + viewport: { width: 1920, height: 1080 }, +}); + const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; const DASHBOARD_NAME = 'Test variable output'; @@ -55,7 +59,7 @@ test.describe( const firstPreviewOption = dashboardPage .getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption) .first(); - await expect(firstPreviewOption).toBeVisible(); + await expect(firstPreviewOption).toBeVisible({ timeout: 15_000 }); const previewOptionText = await firstPreviewOption.textContent(); const previewOption = previewOptionText?.trim() || ''; diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts index 78889efbbbe..6cd56e19a11 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-panel-layouts.spec.ts @@ -12,6 +12,10 @@ test.use({ }, }); +test.use({ + viewport: { width: 1920, height: 1080 }, +}); + test.describe( 'Dashboard Panel Layouts', { @@ -171,6 +175,10 @@ test.describe( test('can change max columns in auto grid layout', async ({ dashboardPage, selectors, page }) => { await importTestDashboard(page, selectors, 'Set max columns'); + await await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first() + ).toBeVisible(); + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); await expect( @@ -384,6 +392,11 @@ async function importTestDashboard(page: Page, selectors: E2ESelectorGroups, tit await page.getByTestId(selectors.components.DataSourcePicker.inputV2).click(); await page.locator('div[data-testid="data-source-card"]').first().click(); await page.getByTestId(selectors.components.ImportDashboardForm.submit).click(); + const undockMenuButton = page.locator('[aria-label="Undock menu"]'); + const undockMenuVisible = await undockMenuButton.isVisible(); + if (undockMenuVisible) { + undockMenuButton.click(); + } } async function saveDashboard(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) { diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-remove-panel.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-remove-panel.spec.ts index 5b5202f1455..61fb78ab5a7 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-remove-panel.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-remove-panel.spec.ts @@ -10,6 +10,10 @@ test.use({ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; +test.use({ + viewport: { width: 1920, height: 1080 }, +}); + test.describe( 'Dashboard panels', { diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts new file mode 100644 index 00000000000..5a72d94f9d6 --- /dev/null +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'ZqZnVvFZz'; + +test.use({ + featureToggles: { + scenes: true, + newDashboardSharingComponent: true, + sharingDashboardImage: true, // Enable the export image feature + }, +}); + +test.describe( + 'Export as Image', + { + tag: ['@dashboards'], + }, + () => { + test('Show renderer not available message when plugin not installed', async ({ + gotoDashboardPage, + page, + selectors, + }) => { + // Navigate to a dashboard + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + }); + + // Open the export dropdown + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.DashNav.NewExportButton.arrowMenu).click(); + + // Click export as image option + await dashboardPage + .getByGrafanaSelector(selectors.pages.Dashboard.DashNav.NewExportButton.Menu.exportAsImage) + .click(); + + // Verify we're on the export image view + await expect(page).toHaveURL(/.*shareView=image/); + + // Verify the "renderer not available" alert is displayed + const rendererAlert = page.getByRole('status'); + await expect(rendererAlert).toBeVisible(); + await expect(rendererAlert).toContainText(/Image renderer plugin not installed/i); + await expect(rendererAlert).toContainText( + /To render an image, you must install the Grafana image renderer plugin/i + ); + + // Verify the generate button is NOT present when renderer is unavailable + await expect(page.getByRole('button', { name: /Generate image/i })).not.toBeVisible(); + }); + } +); diff --git a/e2e-playwright/dashboards/TestV2Dashboard.json b/e2e-playwright/dashboards/TestV2Dashboard.json index 25917da59fa..e4b81d82bf4 100644 --- a/e2e-playwright/dashboards/TestV2Dashboard.json +++ b/e2e-playwright/dashboards/TestV2Dashboard.json @@ -2,32 +2,27 @@ "apiVersion": "dashboard.grafana.app/v2beta1", "kind": "Dashboard", "metadata": { - "name": "fa400625-2a44-4add-a369-e6c972eb4bd6", - "generation": 1, - "creationTimestamp": "2025-05-27T11:40:22Z", - "labels": {}, - "annotations": {} + "name": "addfpww", + "namespace": "default", + "uid": "AvXN09JdxuVNDqODs5IYU2iStlk5ntizuPGIfY1ywZgX", + "resourceVersion": "1", + "generation": 2, + "creationTimestamp": "2025-07-31T13:37:11Z", + "labels": { + "grafana.app/deprecatedInternalID": "6844" + }, + "annotations": { + "grafana.app/createdBy": "user:cejvsh18uudxcf", + "grafana.app/updatedBy": "user:cejvsh18uudxcf", + "grafana.app/updatedTimestamp": "2025-07-31T13:37:11Z", + "grafana.app/folder": "", + "grafana.app/saved-from-ui": "Grafana v12.2.0-pre (69d1226c9a)" + } }, "spec": { - "annotations": [ - { - "kind": "AnnotationQuery", - "spec": { - "builtIn": true, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "query": { - "group": "grafana", - "kind": "DataQuery", - "spec": {}, - "version": "v0" - } - } - } - ], + "annotations": [], "cursorSync": "Off", + "description": "", "editable": true, "elements": { "panel-1": { @@ -42,6 +37,9 @@ "spec": { "hidden": false, "query": { + "datasource": { + "name": "PD8C576611E62080A" + }, "group": "grafana-testdata-datasource", "kind": "DataQuery", "spec": {}, @@ -60,7 +58,8 @@ "links": [], "title": "New panel", "vizConfig": { - "kind": "timeseries", + "group": "timeseries", + "kind": "VizConfig", "spec": { "fieldConfig": { "defaults": { @@ -128,9 +127,9 @@ "mode": "single", "sort": "none" } - }, - "pluginVersion": "12.1.0-pre" - } + } + }, + "version": "12.2.0-pre" } } }, @@ -146,9 +145,15 @@ "spec": { "hidden": false, "query": { + "datasource": { + "name": "PD8C576611E62080A" + }, "group": "grafana-testdata-datasource", "kind": "DataQuery", - "spec": {}, + "spec": { + "scenarioId": "random_walk", + "seriesCount": 1 + }, "version": "v0" }, "refId": "A" @@ -164,7 +169,8 @@ "links": [], "title": "New panel", "vizConfig": { - "kind": "timeseries", + "group": "timeseries", + "kind": "VizConfig", "spec": { "fieldConfig": { "defaults": { @@ -232,9 +238,9 @@ "mode": "single", "sort": "none" } - }, - "pluginVersion": "12.1.0-pre" - } + } + }, + "version": "12.2.0-pre" } } }, @@ -250,6 +256,9 @@ "spec": { "hidden": false, "query": { + "datasource": { + "name": "PD8C576611E62080A" + }, "group": "grafana-testdata-datasource", "kind": "DataQuery", "spec": {}, @@ -268,7 +277,8 @@ "links": [], "title": "New panel", "vizConfig": { - "kind": "timeseries", + "group": "timeseries", + "kind": "VizConfig", "spec": { "fieldConfig": { "defaults": { @@ -336,9 +346,9 @@ "mode": "single", "sort": "none" } - }, - "pluginVersion": "12.1.0-pre" - } + } + }, + "version": "12.2.0-pre" } } } @@ -402,7 +412,7 @@ "timezone": "browser", "to": "now" }, - "title": "Test V2 Dashboard", + "title": "TEST!!!!", "variables": [] }, "status": {} diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 7052ccfc510..c7947fcd281 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -4,12 +4,7 @@ import { test, expect } from '@grafana/plugin-e2e'; const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb'; -test.use({ - viewport: { width: 2000, height: 1080 }, - featureToggles: { - tableNextGen: true, - }, -}); +test.use({ viewport: { width: 2000, height: 1080 }, featureToggles: { tableNextGen: true } }); // helper utils const waitForTableLoad = async (loc: Page | Locator) => { @@ -46,336 +41,324 @@ const getColumnIdx = async (loc: Page | Locator, columnName: string) => { return result; }; -test.describe( - 'Panels test: Table - Kitchen Sink', - { - tag: ['@panels'], - }, - () => { - test('Tests word wrap, hover overflow, and cell inspect', async ({ gotoDashboardPage, selectors, page }) => { - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); +test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] }, () => { + test('Tests word wrap, hover overflow, and cell inspect', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); - // to avoid a race condition when counting up , wait for react-data-grid to finish rendering. - await waitForTableLoad(page); + // to avoid a race condition when counting up , wait for react-data-grid to finish rendering. + await waitForTableLoad(page); - const longTextColIdx = await getColumnIdx(page, 'Long Text'); + const longTextColIdx = await getColumnIdx(page, 'Long Text'); - // text wrapping is enabled by default on this panel. - await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); + // text wrapping is enabled by default on this panel. + await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); - // toggle the lorem ipsum column's wrap text toggle and confirm that the height shrinks. - await dashboardPage - .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')) - .last() - .click(); - await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); + // FIXME very bad selector to get the correct "wrap text" toggle here. + // toggle the lorem ipsum column's wrap text toggle and confirm that the height shrinks. + await page + .locator('[id="Override 13"]') + .locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`) + .click(); + await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); - // test that hover overflow works. - const loremIpsumCell = await getCell(page, 1, longTextColIdx); - await loremIpsumCell.scrollIntoViewIfNeeded(); - await loremIpsumCell.hover(); - await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); - await (await getCell(page, 1, longTextColIdx + 1)).hover(); - await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); + // test that hover overflow works. + const loremIpsumCell = await getCell(page, 1, longTextColIdx); + await loremIpsumCell.scrollIntoViewIfNeeded(); + await loremIpsumCell.hover(); + await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeGreaterThan(100); + await (await getCell(page, 1, longTextColIdx + 1)).hover(); + await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); - // enable cell inspect, confirm that hover no longer triggers. + // enable cell inspect, confirm that hover no longer triggers. + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Cell options Cell value inspect')) + .first() + .locator('label[for="custom.inspect"]') + .click(); + await loremIpsumCell.hover(); + await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); + + // click cell inspect, check that cell inspection pops open in the side as we'd expect. + await loremIpsumCell.getByLabel('Inspect value').click(); + const loremIpsumText = await loremIpsumCell.textContent(); + expect(loremIpsumText).toBeDefined(); + await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); + }); + + test('Tests visibility and display name via overrides', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); + + // confirm that "State" column is hidden by default. + expect(page.getByRole('row').nth(0)).not.toContainText('State'); + + // toggle the "State" column visibility and test that it appears before re-hiding it. + // FIXME this selector is utterly godawful, but there's no way to give testIds or aria-labels or anything to + // the panel editor builder. we should fix that to make e2e's easier to write for our team. + const hideStateColumnSwitch = page.locator('[id="Override 12"]').locator('label').last(); + await hideStateColumnSwitch.click(); + expect(page.getByRole('row').nth(0)).toContainText('State'); + + // now change the display name of the "State" column. + // FIXME it would be good to have a better selector here too. + const displayNameInput = page.locator('[id="Override 12"]').locator('input[value="State"]').last(); + await displayNameInput.fill('State (renamed)'); + await displayNameInput.press('Enter'); + expect(page.getByRole('row').nth(0)).toContainText('State (renamed)'); + }); + + // we test niche cases for sorting, filtering, pagination, etc. in a unit tests already. + // we mainly want to test the happiest paths for these in e2es as well to check for integration + // issues, but the unit tests can confirm that the internal logic works as expected much more quickly and thoroughly. + // hashtag testing pyramid. + test('Tests sorting by column', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); + + // click the "State" column header to sort it. + const stateColumnHeader = await getCell(page, 0, 1); + + await stateColumnHeader.getByText('Info').click(); + await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'ascending'); + expect(getCell(page, 1, 1)).resolves.toContainText('down'); // down or down fast + + await stateColumnHeader.getByText('Info').click(); + await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'descending'); + expect(getCell(page, 1, 1)).resolves.toContainText('up'); // up or up fast + + await stateColumnHeader.getByText('Info').click(); + await expect(stateColumnHeader).not.toHaveAttribute('aria-sort'); + }); + + test('Tests filtering within a column', async ({ gotoDashboardPage, selectors, page }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); + + await waitForTableLoad(page); + + const infoColumnIdx = await getColumnIdx(page, 'Info'); + + const stateColumnHeader = page.getByRole('columnheader').nth(infoColumnIdx); + + // get the first value in the "State" column, filter it out, then check that it went away. + const firstStateValue = (await (await getCell(page, 1, infoColumnIdx)).textContent())!; + await stateColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click(); + const filterContainer = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Visualization.TableNG.Filters.Container + ); + + await expect(filterContainer).toBeVisible(); + + // select all, then click the first value to unselect it, filtering it out. + await filterContainer.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.SelectAll).click(); + await filterContainer.getByTitle(firstStateValue, { exact: true }).locator('label').click(); + await filterContainer.getByRole('button', { name: 'Ok' }).click(); + + // make sure the filter container closed when we clicked "Ok". + await expect(filterContainer).not.toBeVisible(); + + // did it actually filter out our value? + await expect(getCell(page, 1, infoColumnIdx)).resolves.not.toHaveText(firstStateValue); + }); + + test('Tests pagination, row height adjustment', async ({ gotoDashboardPage, selectors, page }) => { + const rowRe = /([\d]+) - ([\d]+) of ([\d]+) rows/; + const getRowStatus = async (page: Page | Locator) => { + const text = (await page.getByText(rowRe).textContent()) ?? ''; + const match = text.match(rowRe); + return { + start: parseInt(match?.[1] ?? '0', 10), + end: parseInt(match?.[2] ?? '0', 10), + total: parseInt(match?.[3] ?? '0', 10), + }; + }; + + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); + + await page + .getByLabel(selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`), { exact: true }) + .click(); + + // because of text wrapping, we're guaranteed to only be showing a single row when we enable pagination. + await expect(page.getByText(/([\d]+) - ([\d]+) of ([\d]+) rows/)).toBeVisible(); + + // FIXME horrible selector for the "Wrap text" toggle for the "Long text" column. + await page + .locator('[id="Override 13"]') + .locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`) + .click(); + + // any number of rows that is not "1" is allowed here, we don't want to police the exact number of rows that + // are rendered since there are tons of factors which could effect this. we do want to grab this number for comparison + // in a second, though. + const smallRowStatus = await getRowStatus(page); + expect(smallRowStatus.end).toBeGreaterThan(1); + expect(page.getByRole('grid').getByRole('row')).toHaveCount(smallRowStatus.end + 1); + + // change cell height to Large + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Table Cell height')) + .locator('input') + .last() + .click(); + const largeRowStatus = await getRowStatus(page); + expect(largeRowStatus.end).toBeLessThan(smallRowStatus.end); + expect(page.getByRole('grid').getByRole('row')).toHaveCount(largeRowStatus.end + 1); + + // click a page over with the directional nav + await page.getByLabel('next page').click(); + const nextPageStatus = await getRowStatus(page); + expect(nextPageStatus.start).toBe(largeRowStatus.end + 1); + expect(nextPageStatus.end).toBe(largeRowStatus.end * 2); + expect(nextPageStatus.total).toBe(largeRowStatus.total); + + // click a page number + await page.getByTestId('data-testid panel content').getByRole('navigation').getByText('4', { exact: true }).click(); + const fourthPageStatus = await getRowStatus(page); + expect(fourthPageStatus.start).toBe(largeRowStatus.end * 3 + 1); + expect(fourthPageStatus.end).toBe(largeRowStatus.end * 4); + expect(fourthPageStatus.total).toBe(largeRowStatus.total); + }); + + test.skip('Tests DataLinks (single and multi) and actions', async ({ gotoDashboardPage, selectors, page }) => { + const addDataLink = async (title: string, url: string) => { await dashboardPage .getByGrafanaSelector( - selectors.components.PanelEditor.OptionsPane.fieldLabel('Cell options Cell value inspect') + selectors.components.PanelEditor.OptionsPane.fieldLabel('Data links and actions Data links') ) - .first() - .locator('label[for="custom.inspect"]') + .locator('button') + .filter({ hasText: 'Add link' }) .click(); - await loremIpsumCell.hover(); - await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); - // click cell inspect, check that cell inspection pops open in the side as we'd expect. - await loremIpsumCell.getByLabel('Inspect value').click(); - const loremIpsumText = await loremIpsumCell.textContent(); - expect(loremIpsumText).toBeDefined(); - await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); + // DataLinks dialog has popped open - fill it in and add a global datalink. + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('dialog').locator('#link-title').fill(title); + await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').focus(); + await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').fill(url); + await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').blur(); + await page.getByRole('dialog').locator('button[aria-disabled="false"]').filter({ hasText: 'Save' }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }; + + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), }); - test('Tests visibility and display name via overrides', async ({ gotoDashboardPage, selectors, page }) => { - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).toBeVisible(); + // disable text wrapping for this test to make it easier to click the links, the long lorem ipsum + // can push the links off the screen. + // FIXME very bad selector to get the correct "wrap text" toggle here. + await page + .locator('[id="Override 13"]') + .locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`) + .click(); - // confirm that "State" column is hidden by default. - expect(page.getByRole('row').nth(0)).not.toContainText('State'); + const infoColumnIdx = await getColumnIdx(page, 'Info'); + const pillColIdx = await getColumnIdx(page, 'Pills'); + const dataLinkColIdx = await getColumnIdx(page, 'Data Link'); - // toggle the "State" column visibility and test that it appears before re-hiding it. - // FIXME this selector is utterly godawful, but there's no way to give testIds or aria-labels or anything to - // the panel editor builder. we should fix that to make e2e's easier to write for our team. - const hideStateColumnSwitch = page.locator('[id="Override 12"]').locator('label').last(); - await hideStateColumnSwitch.click(); - expect(page.getByRole('row').nth(0)).toContainText('State'); + // Info column has a single DataLink by default. + const infoCell = await getCell(page, 1, infoColumnIdx); + await expect(infoCell.locator('a')).toBeVisible(); + expect(infoCell.locator('a')).toHaveAttribute('href'); + expect(infoCell.locator('a')).not.toHaveAttribute('aria-haspopup'); - // now change the display name of the "State" column. - // FIXME it would be good to have a better selector here too. - const displayNameInput = page.locator('[id="Override 12"]').locator('input[value="State"]').last(); - await displayNameInput.fill('State (renamed)'); - await displayNameInput.press('Enter'); - expect(page.getByRole('row').nth(0)).toContainText('State (renamed)'); - }); + // now, add a DataLink to the whole table + await addDataLink('Test link', 'https://grafana.com'); - // we test niche cases for sorting, filtering, pagination, etc. in a unit tests already. - // we mainly want to test the happiest paths for these in e2es as well to check for integration - // issues, but the unit tests can confirm that the internal logic works as expected much more quickly and thoroughly. - // hashtag testing pyramid. - test('Tests sorting by column', async ({ gotoDashboardPage, selectors, page }) => { - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); + // add a DataLink to the whole table, all cells will now have a single link. + const colCount = await page.getByRole('row').nth(1).getByRole('gridcell').count(); + for (let colIdx = 0; colIdx < colCount; colIdx++) { + // - pills column currently does not support DataLinks. + // - we don't apply DataLinks to the DataLinks column itself, since they're rendered inside. + if (colIdx === pillColIdx || colIdx === dataLinkColIdx) { + continue; + } - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).toBeVisible(); + const cell = await getCell(page, 1, colIdx); + await expect(cell.locator('a')).toBeVisible(); + expect(cell.locator('a')).toHaveAttribute('href'); + expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); + } - // click the "State" column header to sort it. - const stateColumnHeader = await getCell(page, 0, 1); + const headerContainer = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer); - await stateColumnHeader.getByText('Info').click(); - await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'ascending'); - expect(getCell(page, 1, 1)).resolves.toContainText('down'); // down or down fast + // add another data link. now we'll check that the multi-link popups work. + await addDataLink('Another test link', 'https://grafana.com/foo'); - await stateColumnHeader.getByText('Info').click(); - await expect(stateColumnHeader).toHaveAttribute('aria-sort', 'descending'); - expect(getCell(page, 1, 1)).resolves.toContainText('up'); // up or up fast - - await stateColumnHeader.getByText('Info').click(); - await expect(stateColumnHeader).not.toHaveAttribute('aria-sort'); - }); - - test('Tests filtering within a column', async ({ gotoDashboardPage, selectors, page }) => { - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); - - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).toBeVisible(); - - await waitForTableLoad(page); - - const infoColumnIdx = await getColumnIdx(page, 'Info'); - - const stateColumnHeader = page.getByRole('columnheader').nth(infoColumnIdx); - - // get the first value in the "State" column, filter it out, then check that it went away. - const firstStateValue = (await (await getCell(page, 1, infoColumnIdx)).textContent())!; - await stateColumnHeader - .getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton) - .click(); - const filterContainer = dashboardPage.getByGrafanaSelector( - selectors.components.Panels.Visualization.TableNG.Filters.Container - ); - - await expect(filterContainer).toBeVisible(); - - // select all, then click the first value to unselect it, filtering it out. - await filterContainer.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.SelectAll).click(); - await filterContainer.getByTitle(firstStateValue, { exact: true }).locator('label').click(); - await filterContainer.getByRole('button', { name: 'Ok' }).click(); - - // make sure the filter container closed when we clicked "Ok". - await expect(filterContainer).not.toBeVisible(); - - // did it actually filter out our value? - await expect(getCell(page, 1, infoColumnIdx)).resolves.not.toHaveText(firstStateValue); - }); - - test('Tests pagination, row height adjustment', async ({ gotoDashboardPage, selectors, page }) => { - const rowRe = /([\d]+) - ([\d]+) of ([\d]+) rows/; - const getRowStatus = async (page: Page | Locator) => { - const text = (await page.getByText(rowRe).textContent()) ?? ''; - const match = text.match(rowRe); - return { - start: parseInt(match?.[1] ?? '0', 10), - end: parseInt(match?.[2] ?? '0', 10), - total: parseInt(match?.[3] ?? '0', 10), - }; - }; - - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); - - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).toBeVisible(); - - await page - .getByLabel(selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`), { exact: true }) - .click(); - - // because of text wrapping, we're guaranteed to only be showing a single row when we enable pagination. - await expect(page.getByText(/([\d]+) - ([\d]+) of ([\d]+) rows/)).toBeVisible(); - - // disable text wrap and see the number of rows. - await dashboardPage - .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')) - .last() - .click(); - - // any number of rows that is not "1" is allowed here, we don't want to police the exact number of rows that - // are rendered since there are tons of factors which could effect this. we do want to grab this number for comparison - // in a second, though. - const smallRowStatus = await getRowStatus(page); - expect(smallRowStatus.end).toBeGreaterThan(1); - expect(page.getByRole('grid').getByRole('row')).toHaveCount(smallRowStatus.end + 1); - - // change cell height to Large - await dashboardPage - .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Table Cell height')) - .locator('input') - .last() - .click(); - const largeRowStatus = await getRowStatus(page); - expect(largeRowStatus.end).toBeLessThan(smallRowStatus.end); - expect(page.getByRole('grid').getByRole('row')).toHaveCount(largeRowStatus.end + 1); - - // click a page over with the directional nav - await page.getByLabel('next page').click(); - const nextPageStatus = await getRowStatus(page); - expect(nextPageStatus.start).toBe(largeRowStatus.end + 1); - expect(nextPageStatus.end).toBe(largeRowStatus.end * 2); - expect(nextPageStatus.total).toBe(largeRowStatus.total); - - // click a page number - await page - .getByTestId('data-testid panel content') - .getByRole('navigation') - .getByText('4', { exact: true }) - .click(); - const fourthPageStatus = await getRowStatus(page); - expect(fourthPageStatus.start).toBe(largeRowStatus.end * 3 + 1); - expect(fourthPageStatus.end).toBe(largeRowStatus.end * 4); - expect(fourthPageStatus.total).toBe(largeRowStatus.total); - }); - - test('Tests DataLinks (single and multi) and actions', async ({ gotoDashboardPage, selectors, page }) => { - const addDataLink = async (title: string, url: string) => { - await dashboardPage - .getByGrafanaSelector( - selectors.components.PanelEditor.OptionsPane.fieldLabel('Data links and actions Data links') - ) - .locator('button') - .filter({ hasText: 'Add link' }) - .click(); - - // DataLinks dialog has popped open - fill it in and add a global datalink. - await expect(page.getByRole('dialog')).toBeVisible(); - await page.getByRole('dialog').locator('#link-title').fill(title); - await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').focus(); - await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').fill(url); - await page.getByRole('dialog').locator('#data-link-input [contenteditable="true"]').blur(); - await page.getByRole('dialog').locator('button[aria-disabled="false"]').filter({ hasText: 'Save' }).click(); - await expect(page.getByRole('dialog')).not.toBeVisible(); - }; - - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '1' }), - }); - - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).toBeVisible(); - - // disable text wrapping for this test to make it easier to click the links, the long lorem ipsum - // can push the links off the screen. - await dashboardPage - .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')) - .last() - .click(); - - const infoColumnIdx = await getColumnIdx(page, 'Info'); - const pillColIdx = await getColumnIdx(page, 'Pills'); - const dataLinkColIdx = await getColumnIdx(page, 'Data Link'); - - // Info column has a single DataLink by default. - const infoCell = await getCell(page, 1, infoColumnIdx); - await expect(infoCell.locator('a')).toBeVisible(); - expect(infoCell.locator('a')).toHaveAttribute('href'); - expect(infoCell.locator('a')).not.toHaveAttribute('aria-haspopup'); - - // now, add a DataLink to the whole table - await addDataLink('Test link', 'https://grafana.com'); - - // add a DataLink to the whole table, all cells will now have a single link. - const colCount = await page.getByRole('row').nth(1).getByRole('gridcell').count(); - for (let colIdx = 0; colIdx < colCount; colIdx++) { - // - pills column currently does not support DataLinks. - // - we don't apply DataLinks to the DataLinks column itself, since they're rendered inside. - if (colIdx === pillColIdx || colIdx === dataLinkColIdx) { - continue; - } - - const cell = await getCell(page, 1, colIdx); - await expect(cell.locator('a')).toBeVisible(); - expect(cell.locator('a')).toHaveAttribute('href'); + // loop thru the columns, click the links, observe that the tooltip appears, and close the tooltip. + for (let colIdx = 0; colIdx < colCount; colIdx++) { + const cell = await getCell(page, 1, colIdx); + if (colIdx === infoColumnIdx) { + // the Info column should still have its single link. expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); + continue; } - const headerContainer = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer); - - // add another data link. now we'll check that the multi-link popups work. - await addDataLink('Another test link', 'https://grafana.com/foo'); - - // loop thru the columns, click the links, observe that the tooltip appears, and close the tooltip. - for (let colIdx = 0; colIdx < colCount; colIdx++) { - const cell = await getCell(page, 1, colIdx); - if (colIdx === infoColumnIdx) { - // the Info column should still have its single link. - expect(cell.locator('a')).not.toHaveAttribute('aria-haspopup', 'menu'); - continue; - } - - // - pills column currently does not support DataLinks. - // - we don't apply DataLinks to the DataLinks column itself, since they're rendered inside. - if (colIdx === pillColIdx || colIdx === dataLinkColIdx) { - continue; - } - - await cell.locator('a').click({ force: true }); - await expect(page.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).toBeVisible(); - - await headerContainer.click(); // convenient just to click the header to close the tooltip. - await expect(page.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeVisible(); + // - pills column currently does not support DataLinks. + // - we don't apply DataLinks to the DataLinks column itself, since they're rendered inside. + if (colIdx === pillColIdx || colIdx === dataLinkColIdx) { + continue; } - // add an Action to the whole table and check that the action button is added to the tooltip. - // TODO -- saving for another day. + await cell.locator('a').click({ force: true }); + await expect(page.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).toBeVisible(); + + await headerContainer.click(); // convenient just to click the header to close the tooltip. + await expect(page.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeVisible(); + } + + // add an Action to the whole table and check that the action button is added to the tooltip. + // TODO -- saving for another day. + }); + + test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '2' }), }); - test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => { - const dashboardPage = await gotoDashboardPage({ - uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '2' }), - }); - - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage) - ).toBeVisible(); - await expect( - dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) - ).not.toBeVisible(); - }); - } -); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage) + ).toBeVisible(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).not.toBeVisible(); + }); +}); diff --git a/e2e-playwright/panels-suite/table-sparkline.spec.ts b/e2e-playwright/panels-suite/table-sparkline.spec.ts index 16dc55f3df4..d2ac814e2df 100644 --- a/e2e-playwright/panels-suite/table-sparkline.spec.ts +++ b/e2e-playwright/panels-suite/table-sparkline.spec.ts @@ -1,29 +1,18 @@ import { test, expect } from '@grafana/plugin-e2e'; -test.use({ - viewport: { width: 1280, height: 1080 }, - featureToggles: { - tableNextGen: true, - }, -}); +test.use({ viewport: { width: 1280, height: 1080 }, featureToggles: { tableNextGen: true } }); -test.describe( - 'Panels test: Table - Sparkline', - { - tag: ['@panels'], - }, - () => { - test('Tests sparkline tables are successfully rendered', async ({ gotoDashboardPage, selectors, page }) => { - await gotoDashboardPage({ - uid: 'd6373b49-1957-4f00-9218-ee2120d3ecd9', - queryParams: new URLSearchParams({ editPanel: '2' }), - }); - - await expect(page.getByRole('grid')).toBeVisible(); - - const uplotCount = await page.locator('.uplot').count(); - const rowCount = await page.getByRole('row').count(); - expect(uplotCount).toBe(rowCount - 1); +test.describe('Panels test: Table - Sparkline', { tag: ['@panels', '@table'] }, () => { + test('Tests sparkline tables are successfully rendered', async ({ gotoDashboardPage, selectors, page }) => { + await gotoDashboardPage({ + uid: 'd6373b49-1957-4f00-9218-ee2120d3ecd9', + queryParams: new URLSearchParams({ editPanel: '2' }), }); - } -); + + await expect(page.getByRole('grid')).toBeVisible(); + + const uplotCount = await page.locator('.uplot').count(); + const rowCount = await page.getByRole('row').count(); + expect(uplotCount).toBe(rowCount - 1); + }); +}); diff --git a/e2e/old-arch/various-suite/helpers/prometheus-helpers.ts b/e2e/old-arch/various-suite/helpers/prometheus-helpers.ts index 265095ecd65..0102bcec3ab 100644 --- a/e2e/old-arch/various-suite/helpers/prometheus-helpers.ts +++ b/e2e/old-arch/various-suite/helpers/prometheus-helpers.ts @@ -24,11 +24,11 @@ export function createPromDS(dataSourceID: string, name: string): void { } export function getResources() { - cy.intercept(/__name__/g, metricResponse); + cy.intercept(/__name__/g, metricResponse).as('getMetricNames'); - cy.intercept(/metadata/g, metadataResponse); + cy.intercept(/metadata/g, metadataResponse).as('getMetadata'); - cy.intercept(/labels/g, labelsResponse); + cy.intercept(/labels/g, labelsResponse).as('getLabels'); } const metricResponse = { diff --git a/e2e/old-arch/various-suite/prometheus-editor.spec.ts b/e2e/old-arch/various-suite/prometheus-editor.spec.ts index 4a2b5aed7a3..7b3093f4f5d 100644 --- a/e2e/old-arch/various-suite/prometheus-editor.spec.ts +++ b/e2e/old-arch/various-suite/prometheus-editor.spec.ts @@ -138,12 +138,11 @@ describe('Prometheus query editor', () => { it('can select a metric and provide a hint', () => { navigateToEditor('Builder', 'prometheusBuilder'); - getResources(); - - e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click().type('metric1'); + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click(); + cy.wait('@getMetadata'); + e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().type('metric1'); selectOption('metric1'); - e2e.components.DataSource.Prometheus.queryEditor.builder.hints().contains('hint: add rate'); }); diff --git a/e2e/plugin-e2e/canvas/canvas-scene.spec.ts b/e2e/plugin-e2e/canvas/canvas-scene.spec.ts new file mode 100644 index 00000000000..b640f450223 --- /dev/null +++ b/e2e/plugin-e2e/canvas/canvas-scene.spec.ts @@ -0,0 +1,87 @@ +import { Locator } from '@playwright/test'; + +import { test, expect } from '@grafana/plugin-e2e'; + +test.use({ + featureToggles: { + canvasPanelPanZoom: true, + }, +}); + +test.describe('Canvas Panel - Scene Tests', () => { + test.beforeEach(async ({ page, gotoDashboardPage }) => { + const dashboardPage = await gotoDashboardPage({}); + const panelEditPage = await dashboardPage.addPanel(); + await panelEditPage.setVisualization('Canvas'); + + // Wait for canvas panel to load + await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 }); + }); + + test('should create and render canvas panel with scene elements', async ({ page }) => { + const canvasElement = await page.getByRole('button', { name: 'Double click to set field' }); + await expect(canvasElement).toBeVisible(); + }); + + test('should handle scene pan and zoom when enabled', async ({ page }) => { + // Feature toggle is enabled, pan/zoom functionality should be available + const panZoomCheckbox = await page.getByLabel('Canvas Pan and zoom field').locator('label').nth(1); + await panZoomCheckbox.setChecked(true); + await expect(panZoomCheckbox).toBeChecked({ checked: true }); + + const canvasElement = await page.getByRole('button', { name: 'Double click to set field' }); + const canvasSceneWrapper = await page.getByTestId('canvas-scene-wrapper'); + + // Check if infinite viewer is present (pan/zoom feature) + await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 }); + const infiniteViewer = page.locator('[data-testid="canvas-scene-pan-zoom"]'); + await infiniteViewer.waitFor({ state: 'visible', timeout: 5000 }); + await expect(await infiniteViewer.isVisible()).toBe(true); + await infiniteViewer.hover(); + + const viewerBounds = await infiniteViewer.boundingBox(); + await expect(viewerBounds).toBeDefined(); + + // Test pan functionality + const startX = viewerBounds.x + 50; + const startY = viewerBounds.y + 50; + const endX = viewerBounds.x + 250; + const endY = viewerBounds.y + 250; + await page.getByTestId('canvas-scene-pan-zoom'); + await page.mouse.move(startX, startY); + await page.mouse.down({ button: 'middle' }); + await page.mouse.move(endX, endY); + await page.mouse.up({ button: 'middle' }); + await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(true); + + // Test zoom reset with double-click + await page.mouse.dblclick(startX, startY); + // Verify canvas element is visible after pan/zoom operations + await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(false); + + // Test zoom functionality + await page.mouse.move(startX, startY); + await page.keyboard.down('Control'); + await page.mouse.wheel(0, -400); // Zoom in + await page.keyboard.up('Control'); + // Check if canvas element is not visible after zoom operations + await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(true); + + // Test zoom reset with double-click + await page.mouse.dblclick(startX, startY); + // Verify canvas element is visible after pan/zoom operations + await expect(await isOutsideViewport(canvasElement, canvasSceneWrapper)).toBe(false); + }); +}); + +// TODO: this function is workaround for .toBeVisible() +async function isOutsideViewport(element: Locator, viewPort: Locator): Promise { + const elementBounds = await element.boundingBox(); + const viewportBounds = await viewPort.boundingBox(); + return ( + elementBounds.x + elementBounds.width < viewportBounds.x || + elementBounds.x > viewportBounds.x + viewportBounds.width || + elementBounds.y + elementBounds.height < viewportBounds.y || + elementBounds.y > viewportBounds.y + viewportBounds.height + ); +} diff --git a/go.mod b/go.mod index dc72d4b349b..f8c2a0a7b16 100644 --- a/go.mod +++ b/go.mod @@ -78,14 +78,14 @@ require ( github.com/golang/protobuf v1.5.4 // @grafana/grafana-backend-group github.com/golang/snappy v1.0.0 // @grafana/alerting-backend github.com/google/go-cmp v0.7.0 // @grafana/grafana-backend-group - github.com/google/go-github/v70 v70.0.0 // @grafana/grafana-app-platform-squad + github.com/google/go-github/v70 v70.0.0 // @grafana/grafana-git-ui-sync-team github.com/google/go-querystring v1.1.0 // indirect; @grafana/oss-big-tent github.com/google/uuid v1.6.0 // @grafana/grafana-backend-group github.com/google/wire v0.6.0 // @grafana/grafana-backend-group github.com/googleapis/gax-go/v2 v2.14.2 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -95,8 +95,8 @@ require ( github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f // @grafana/sharing-squad github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend - github.com/grafana/grafana-app-sdk v0.40.1 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-app-sdk/logging v0.40.0 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana-app-sdk v0.40.2 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana-app-sdk/logging v0.40.1 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-aws-sdk v1.0.4 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad @@ -104,7 +104,7 @@ require ( github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group github.com/grafana/grafana-plugin-sdk-go v0.278.0 // @grafana/plugins-platform-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs - github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 // @grafana-app-platform-squad + github.com/grafana/nanogit v0.0.0-20250723104447-68f58f5ecec0 // @grafana/grafana-git-ui-sync-team github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // @grafana/observability-traces-and-profiling github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling @@ -135,7 +135,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // @grafana/grafana-backend-group github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-backend github.com/microsoft/go-mssqldb v1.8.0 // @grafana/partner-datasources - github.com/migueleliasweb/go-github-mock v1.1.0 // @grafana/grafana-app-platform-squad + github.com/migueleliasweb/go-github-mock v1.1.0 // @grafana/grafana-git-ui-sync-team github.com/mitchellh/copystructure v1.2.0 // @grafana/grafana-operator-experience-squad github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c //@grafana/identity-access-team github.com/mocktools/go-smtp-mock/v2 v2.3.1 // @grafana/grafana-backend-group @@ -221,6 +221,7 @@ require ( k8s.io/kube-aggregator v0.33.3 // @grafana/grafana-app-platform-squad k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // @grafana/grafana-app-platform-squad k8s.io/utils v0.0.0-20241210054802-24370beab758 // @grafana/partner-datasources + modernc.org/sqlite v1.37.0 // @grafana/grafana-backend-group pgregory.net/rapid v1.2.0 // @grafana/grafana-operator-experience-squad sigs.k8s.io/randfill v1.0.0 // @grafana/grafana-app-platform-squad sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // @grafana-app-platform-squad @@ -230,12 +231,12 @@ require ( require ( github.com/grafana/grafana/apps/advisor v0.0.0-20250627191313-2f1a6ae1712b // @grafana/plugins-platform-backend github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250627191313-2f1a6ae1712b // @grafana/alerting-backend - github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad + github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad github.com/grafana/grafana/apps/folder v0.0.0-20250627191313-2f1a6ae1712b // @grafana/grafana-search-and-storage github.com/grafana/grafana/apps/iam v0.0.0-20250627191313-2f1a6ae1712b // @grafana/identity-access-team github.com/grafana/grafana/apps/investigations v0.0.0-20250627191313-2f1a6ae1712b // @fcjack @matryer github.com/grafana/grafana/apps/playlist v0.0.0-20250627191313-2f1a6ae1712b // @grafana/grafana-app-platform-squad - github.com/grafana/grafana/apps/secret v0.0.0-20250711114246-c9b2126c4ad5 // @grafana/grafana-operator-experience-squad + github.com/grafana/grafana/apps/secret v0.0.0-20250731151929-0aac22a9e2d3 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana/pkg/aggregator v0.0.0-20250627191313-2f1a6ae1712b // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250711114246-c9b2126c4ad5 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/apiserver v0.0.0-20250627191313-2f1a6ae1712b // @grafana/grafana-app-platform-squad @@ -596,7 +597,6 @@ require ( modernc.org/libc v1.65.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.10.0 // indirect - modernc.org/sqlite v1.37.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/yaml v1.5.0 // indirect diff --git a/go.sum b/go.sum index 46799064e05..d3ce4af5ca7 100644 --- a/go.sum +++ b/go.sum @@ -1580,8 +1580,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b h1:mfUAq/N+mS82EcE35hDXWtfVY7UhTjzZxzssvFt9tvQ= -github.com/grafana/alerting v0.0.0-20250725130805-615c8286e14b/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= +github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 h1:hcr/AmPB0KL4H+gCEFIdKUnkihTxGAkAOiZA7GDYoL8= +github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 h1:vVPT0i5Y1vI6qzecYStV2yk7cHKrC3Pc7AgvwT5KydQ= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/oUn6Cr90QbJYpQJ4FnjyAIG9Ex5GtTZIzw= @@ -1600,10 +1600,10 @@ github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d h1:oXRJlb9UjVsl github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8= github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y= -github.com/grafana/grafana-app-sdk v0.40.1 h1:W8d1CQSgMg/d37xf/0t4PiSa7wVD21XFE8mF6P59YSg= -github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= +github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-aws-sdk v1.0.4 h1:D14UAehsOqpjliHmHzveRQ1p43KCsMzdmb7GovWj+SY= github.com/grafana/grafana-aws-sdk v1.0.4/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= @@ -1620,8 +1620,8 @@ github.com/grafana/grafana/apps/advisor v0.0.0-20250627191313-2f1a6ae1712b h1:8o github.com/grafana/grafana/apps/advisor v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:q+h3HbmqU/PposW6lq8cMle1v8vuyX1LCMrGzbabHxc= github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250627191313-2f1a6ae1712b h1:jr+C3epmjhd5Yyob4P1Z/dPaW4LRTkU5UJLXsI4eaeM= github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:WpI7TCck4P2wKTO2WJLBRcfOWvUGvTdxYu3QqS3z7jM= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441 h1:+TSbaxCXBZrKkdROWBzdWna8uStE1f9LYd7GiqjVfz8= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250716132114-6fd75ebc5441/go.mod h1:1XWiRSVuDQiayapHhQiDc4S4e9GzEZgg/3GeNCuDgn4= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017 h1:Niy+KRDWHsUVqfhZQg0oZbAQFO6QcO6a4l9V/ouDEEs= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250730164619-34019e5ec017/go.mod h1:/iuseD/cEpXDiy7MpL+4qBFZ3H6esnUJTYzpoJMw9dw= github.com/grafana/grafana/apps/folder v0.0.0-20250627191313-2f1a6ae1712b h1:31MwoIKKT9Ay0ZjbT4lkfcPijiWogUWzXs2EjrCgodI= github.com/grafana/grafana/apps/folder v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:dLtYBp1pza5HYalezNvzlP8JDeKrZ5BKTonDgEOE0NY= github.com/grafana/grafana/apps/iam v0.0.0-20250627191313-2f1a6ae1712b h1:NV8v9xdM/pzjjy+1cLqUseia3bYcvQGh88vZdMW/jA0= @@ -1630,8 +1630,8 @@ github.com/grafana/grafana/apps/investigations v0.0.0-20250627191313-2f1a6ae1712 github.com/grafana/grafana/apps/investigations v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:8RlQ4U9lccPEBD/QxV4zyIMh9+lzjS/7xGpiqn3cHLY= github.com/grafana/grafana/apps/playlist v0.0.0-20250627191313-2f1a6ae1712b h1:elfpvk06igCjE0yL+/urc69UDOt1B/sPfdNg9X9kUMc= github.com/grafana/grafana/apps/playlist v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:fPtx6dwGm0PweQRVbgtthMapJMvXobBcORbndb7Dgd4= -github.com/grafana/grafana/apps/secret v0.0.0-20250711114246-c9b2126c4ad5 h1:+fMhUoqwGdY8ntH0GL2icJa3uk+bTiIMicawDG2r9Uc= -github.com/grafana/grafana/apps/secret v0.0.0-20250711114246-c9b2126c4ad5/go.mod h1:TIrKvhgo2j6lvVeOZ3TUmXbI4I48d6v7QcadL/f6SKQ= +github.com/grafana/grafana/apps/secret v0.0.0-20250731151929-0aac22a9e2d3 h1:16eaVEucbwis3TxS4CYZxxg5wfPAP/6u7Ji2+wbiHyk= +github.com/grafana/grafana/apps/secret v0.0.0-20250731151929-0aac22a9e2d3/go.mod h1:pS2M5ILsHx9VNTM96glLtCjCVXHWyfGcT34WHvbbMtM= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250627191313-2f1a6ae1712b h1:ei01IFqmnXkOrrVvsT3CYe+i5xYra3SCX7Wsu3PMsDU= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250627191313-2f1a6ae1712b/go.mod h1:+H4Va9jDJlGQJjAN+OFD/hLx2I/yEzDRMQLaKecvgAc= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250711114246-c9b2126c4ad5 h1:f4fopIH6eQRoZ/E7bstn69UtDAHleIdQ6DrdzEs++Ug= diff --git a/go.work.sum b/go.work.sum index cd47b184cb0..d129bd22b1f 100644 --- a/go.work.sum +++ b/go.work.sum @@ -16,6 +16,7 @@ cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go v0.118.1/go.mod h1:CFO4UPEPi8oV21xoezZCrd3d81K4fFkDTEJu4R8K+9M= @@ -516,7 +517,9 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk= github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.7.1 h1:o/Ws6bEqMeKZUfj1RRm3mQ51O8JGU5w+Qdg2AhHib6A= github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.7.1/go.mod h1:6QAMYBAbQeeKX+REFJMZ1nFWu9XLw/PPcjYpuc9RDFs= @@ -705,6 +708,7 @@ github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nC github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c h1:2zRrJWIt/f9c9HhNHAgrRgq0San5gRRUJTBXLkchal0= github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= @@ -902,6 +906,7 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E= github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= @@ -964,12 +969,17 @@ github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/ github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak= github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90= github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= +github.com/grafana/grafana-app-sdk v0.40.0/go.mod h1:fn943JEM0CR3mY/Gd3816MUcpob5xnKc8MoojnbMjYY= github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= +github.com/grafana/grafana-app-sdk/logging v0.39.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0= github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s= +github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss= +github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= +github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q= github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI= @@ -1051,7 +1061,6 @@ github.com/jaegertracing/jaeger v1.67.0 h1:t0BiJZVW9D3Z16y3uHqKzV9bKFTusooTH1Kgr github.com/jaegertracing/jaeger v1.67.0/go.mod h1:tE/FEQfybCSdUbBgel51YaCSkc58O+Njih8oTl6j8vw= github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= -github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jhump/gopoet v0.1.0 h1:gYjOPnzHd2nzB37xYQZxj4EIQNpBrBskRqQQ3q4ZgSg= github.com/jhump/goprotoc v0.5.0 h1:Y1UgUX+txUznfqcGdDef8ZOVlyQvnV0pKWZH08RmZuo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -1285,6 +1294,7 @@ github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/richardartoul/molecule v1.0.0 h1:+LFA9cT7fn8KF39zy4dhOnwcOwRoqKiBkPqKqya+8+U= @@ -1353,8 +1363,6 @@ github.com/testcontainers/testcontainers-go/modules/mongodb v0.35.0 h1:i1Kh9fmXg github.com/testcontainers/testcontainers-go/modules/mongodb v0.35.0/go.mod h1:SD8nVMK1m7b/K2YJqYjYNzfHmZfqHtqNOlI44nfxjdg= github.com/testcontainers/testcontainers-go/modules/redis v0.35.0 h1:RBgVefU5j5IWapp3TNKqMTYX+M22OSjtuORjPd4+g08= github.com/testcontainers/testcontainers-go/modules/redis v0.35.0/go.mod h1:UgghVXQ0//D3MjC8X71Bpb/lUCChidjNCRILD+btqfU= -github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= -github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -1434,7 +1442,6 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 h1:ixAiqjj2S/dNuJqrz4AxSqgw2P5OBM github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok9Sjc16tEC8AXGbwrk+ho= github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -1451,6 +1458,7 @@ go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.etcd.io/gofail v0.2.0 h1:p19drv16FKK345a09a1iubchlw/vmRuksmRzgBIGjcA= go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= go.opentelemetry.io/collector v0.124.0 h1:g/dfdGFhBcQI0ggGxTmGlJnJ6Yl6T2gVxQoIj4UfXCc= go.opentelemetry.io/collector v0.124.0/go.mod h1:QzERYfmHUedawjr8Ph/CBEEkVqWS8IlxRLAZt+KHlCg= go.opentelemetry.io/collector/client v1.30.0 h1:QbvOrvwUGcnVjnIBn2zyLLubisOjgh7kMgkzDAiYpHg= @@ -1709,6 +1717,7 @@ golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFK golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b h1:DU+gwOBXU+6bO0sEyO7o/NeMlxZxCZEvI7v+J4a1zRQ= golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= @@ -1732,6 +1741,7 @@ golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= @@ -1806,7 +1816,7 @@ google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3i google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -1833,6 +1843,7 @@ honnef.co/go/tools v0.3.2 h1:ytYb4rOqyp1TSa2EPvNVwtPQJctSELKaMyLfqNP4+34= honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= k8s.io/code-generator v0.33.1 h1:ZLzIRdMsh3Myfnx9BaooX6iQry29UJjVfVG+BuS+UMw= k8s.io/code-generator v0.33.1/go.mod h1:HUKT7Ubp6bOgIbbaPIs9lpd2Q02uqkMCMx9/GjDrWpY= k8s.io/code-generator v0.33.2 h1:PCJ0Y6viTCxxJHMOyGqYwWEteM4q6y1Hqo2rNpl6jF4= @@ -1869,3 +1880,4 @@ sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2Lfw sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e h1:4Z09Hglb792X0kfOBBJUPFEyvVfQWrYT/l8h5EKA6JQ= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= diff --git a/jest.config.js b/jest.config.js index 6041aaa2b20..a576f4ec0c4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -83,6 +83,7 @@ module.exports = { '/public/app/plugins/datasource/grafana-pyroscope-datasource', '/public/app/plugins/datasource/grafana-testdata-datasource', '/public/app/plugins/datasource/jaeger', + '/public/app/plugins/datasource/loki', '/public/app/plugins/datasource/mysql', '/public/app/plugins/datasource/parca', '/public/app/plugins/datasource/tempo', diff --git a/package.json b/package.json index 278ffb6efcc..7626f9f04b1 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "plugin:build": "nx run-many -t build --projects='tag:scope:plugin'", "plugin:build:commit": "nx run-many -t build:commit --projects='tag:scope:plugin'", "plugin:build:dev": "nx run-many -t dev --projects='tag:scope:plugin' --maxParallel=100", - "plugin:test:ci": "nx run-many -t test:ci --projects='tag:scope:plugin'", + "plugin:test:ci": "nx run-many -t test:ci --projects='tag:scope:plugin' --maxParallel=2", "plugin:i18n-extract": "nx run-many -t i18n-extract --projects='tag:scope:plugin'", "process-specs": "node --experimental-strip-types scripts/process-specs.ts", "generate-apis": "yarn process-specs && rtk-query-codegen-openapi ./scripts/generate-rtk-apis.ts", @@ -93,20 +93,20 @@ "@grafana/plugin-e2e": "2.1.7", "@grafana/test-utils": "workspace:*", "@grafana/tsconfig": "^2.0.0", - "@manypkg/get-packages": "^2.2.0", - "@npmcli/package-json": "^5.2.0", + "@manypkg/get-packages": "^3.0.0", + "@npmcli/package-json": "^6.0.0", "@playwright/test": "1.54.1", "@pmmmwh/react-refresh-webpack-plugin": "0.6.1", - "@react-types/button": "3.10.2", - "@react-types/menu": "3.9.14", - "@react-types/overlays": "3.8.12", - "@react-types/shared": "3.27.0", - "@rsdoctor/webpack-plugin": "^0.4.6", + "@react-types/button": "3.13.0", + "@react-types/menu": "3.10.3", + "@react-types/overlays": "3.9.0", + "@react-types/shared": "3.31.0", + "@rsdoctor/webpack-plugin": "^1.0.0", "@rtk-query/codegen-openapi": "^2.0.0", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", - "@stylistic/eslint-plugin-ts": "^3.0.0", - "@swc/core": "1.10.12", - "@swc/helpers": "0.5.15", + "@stylistic/eslint-plugin-ts": "^4.0.0", + "@swc/core": "1.13.3", + "@swc/helpers": "0.5.17", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.3.0", @@ -121,9 +121,9 @@ "@types/d3-scale-chromatic": "3.1.0", "@types/debounce-promise": "3.1.9", "@types/eslint": "9.6.1", - "@types/eslint-scope": "^3.7.7", + "@types/eslint-scope": "^8.0.0", "@types/file-saver": "2.0.7", - "@types/glob": "^8.0.0", + "@types/glob": "^9.0.0", "@types/google.analytics": "^0.0.46", "@types/gtag.js": "^0.0.20", "@types/history": "4.7.11", @@ -135,7 +135,7 @@ "@types/lodash": "4.17.20", "@types/logfmt": "^1.2.3", "@types/lucene": "^2", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/node-forge": "^1", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.3.0", "@types/pluralize": "^0.0.33", @@ -164,17 +164,17 @@ "@types/webpack-assets-manifest": "^5", "@types/webpack-env": "^1.18.4", "@types/yargs": "17.0.33", - "@typescript-eslint/eslint-plugin": "8.35.1", - "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", "autoprefixer": "10.4.21", - "babel-loader": "9.2.1", + "babel-loader": "10.0.0", "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", "chance": "^1.1.13", "chrome-remote-interface": "0.33.3", "codeowners": "^5.1.1", "confusing-browser-globals": "^1.0.11", - "copy-webpack-plugin": "12.0.2", + "copy-webpack-plugin": "13.0.0", "core-js": "3.44.0", "crashme": "0.0.15", "css-loader": "7.1.2", @@ -243,15 +243,15 @@ "sass-loader": "16.0.5", "smtp-tester": "^2.1.0", "style-loader": "4.0.0", - "stylelint": "16.14.1", + "stylelint": "16.23.0", "stylelint-config-sass-guidelines": "12.1.0", "terser-webpack-plugin": "5.3.14", "testing-library-selector": "0.3.1", "tracelib": "1.0.1", - "ts-jest": "29.2.5", + "ts-jest": "29.4.0", "ts-node": "10.9.2", - "typescript": "5.8.3", - "webpack": "5.97.1", + "typescript": "5.9.2", + "webpack": "5.101.0", "webpack-assets-manifest": "^5.1.0", "webpack-cli": "6.0.1", "webpack-livereload-plugin": "3.0.2", @@ -268,7 +268,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@fingerprintjs/fingerprintjs": "^3.4.2", - "@floating-ui/react": "0.27.14", + "@floating-ui/react": "0.27.15", "@formatjs/intl-durationformat": "^0.7.0", "@glideapps/glide-data-grid": "^6.0.0", "@grafana/alerting": "workspace:*", @@ -290,8 +290,8 @@ "@grafana/plugin-ui": "0.10.7", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "^6.27.2", - "@grafana/scenes-react": "^6.27.2", + "@grafana/scenes": "6.29.0", + "@grafana/scenes-react": "6.29.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -310,10 +310,10 @@ "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.36.0", "@popperjs/core": "2.11.8", - "@react-aria/dialog": "3.5.27", - "@react-aria/focus": "3.20.5", - "@react-aria/overlays": "3.27.3", - "@react-aria/utils": "3.29.1", + "@react-aria/dialog": "3.5.28", + "@react-aria/focus": "3.21.0", + "@react-aria/overlays": "3.28.0", + "@react-aria/utils": "3.30.0", "@react-awesome-query-builder/ui": "6.6.15", "@reduxjs/toolkit": "2.8.2", "@visx/event": "3.12.0", @@ -347,6 +347,7 @@ "i18next-pseudo": "^2.2.1", "immer": "10.1.1", "immutable": "5.1.3", + "infinite-viewer": "^0.29.1", "ix": "^7.0.0", "jquery": "3.7.1", "js-yaml": "^4.1.0", @@ -406,7 +407,6 @@ "react-virtualized-auto-sizer": "1.0.26", "react-window": "1.8.11", "react-window-infinite-loader": "1.0.10", - "react-zoom-pan-pinch": "^3.3.0", "reduce-reducers": "^1.0.4", "redux": "5.0.1", "redux-thunk": "3.1.0", diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index a777abcc684..27ba36f953a 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -78,7 +78,7 @@ "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", "type-fest": "^4.40.0", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "@grafana/runtime": ">=11.6 <= 12.x", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 99cf6ee0f52..77cbb8f36d6 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -88,7 +88,7 @@ "@rollup/plugin-node-resolve": "16.0.1", "@types/history": "4.7.11", "@types/lodash": "4.17.20", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/papaparse": "5.3.16", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", @@ -100,7 +100,7 @@ "rollup": "^4.22.4", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-data/src/field/fieldState.ts b/packages/grafana-data/src/field/fieldState.ts index 3ddaebbda63..2501444a3a0 100644 --- a/packages/grafana-data/src/field/fieldState.ts +++ b/packages/grafana-data/src/field/fieldState.ts @@ -62,7 +62,7 @@ export function cacheFieldDisplayNames(frames: DataFrame[]) { /** * * moves each field's config.custom.hideFrom to field.state.hideFrom - * and mutates orgiginal field.config.custom.hideFrom to one with explicit overrides only, (without the ad-hoc stateful __system override from legend toggle) + * and mutates original field.config.custom.hideFrom to one with explicit overrides only, (without the ad-hoc stateful __system override from legend toggle) */ export function decoupleHideFromState(frames: DataFrame[], fieldConfig: FieldConfigSource) { frames.forEach((frame) => { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 90565ef3eb0..68564d1ce0e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -282,10 +282,6 @@ export interface FeatureToggles { */ kubernetesLibraryPanels?: boolean; /** - * Routes library panel connections requests from /api to using search - */ - kubernetesLibraryPanelConnections?: boolean; - /** * Use the kubernetes API in the frontend for dashboards */ kubernetesDashboards?: boolean; @@ -302,6 +298,10 @@ export interface FeatureToggles { */ dashboardSchemaValidationLogging?: boolean; /** + * Enable fallback parsing behavior when scan row encounters invalid dashboard JSON + */ + scanRowInvalidDashboardParseFallbackEnabled?: boolean; + /** * Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) */ datasourceQueryTypes?: boolean; @@ -457,6 +457,11 @@ export interface FeatureToggles { */ scopeApi?: boolean; /** + * Use the single node endpoint for the scope api. This is used to fetch the scope parent node. + * @default false + */ + useScopeSingleNodeEndpoint?: boolean; + /** * In-development feature that will allow injection of labels into prometheus queries. * @default true */ @@ -1074,6 +1079,10 @@ export interface FeatureToggles { */ dashboardDsAdHocFiltering?: boolean; /** + * Supports __from and __to macros that always use the dashboard level time range + */ + dashboardLevelTimeMacros?: boolean; + /** * Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. * @default false */ @@ -1082,4 +1091,8 @@ export interface FeatureToggles { * Enable adhoc filter buttons in visualization tooltips */ adhocFiltersInTooltips?: boolean; + /** + * New Log Context component + */ + newLogContext?: boolean; } diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 0cb4a61a25b..34680fc07d1 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -120,6 +120,8 @@ export const availableIconsIndex = { 'file-export': true, 'file-landscape-alt': true, filter: true, + 'filter-plus': true, + 'filter-minus': true, flip: true, folder: true, font: true, diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index d647d0fb88d..bc25b3d1bb3 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@rollup/plugin-node-resolve": "16.0.1", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/semver": "7.7.0", "esbuild": "0.25.8", "rimraf": "6.0.1", @@ -52,6 +52,6 @@ "@grafana/tsconfig": "^2.0.0", "semver": "^7.7.0", "tslib": "2.8.1", - "typescript": "5.8.3" + "typescript": "5.9.2" } } diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index adf4f2efbfe..9ac23a5b24c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1239,6 +1239,9 @@ export const versionedComponents = { title: { [MIN_GRAFANA_VERSION]: (title: string) => `data-testid dashboard-row-title-${title}`, }, + wrapper: { + '12.1.0': (title: string) => `data-testid dashboard-row-wrapper-for-${title}`, + }, }, UserProfile: { profileSaveButton: { diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index f8597f17c32..c25abdd0104 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -68,7 +68,7 @@ "@types/d3": "^7", "@types/jest": "^29.5.4", "@types/lodash": "4.17.20", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/react": "18.3.18", "@types/react-virtualized-auto-sizer": "1.0.8", "@types/tinycolor2": "1.4.6", @@ -79,9 +79,9 @@ "rollup": "^4.22.4", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", - "ts-jest": "29.2.5", + "ts-jest": "29.4.0", "ts-node": "10.9.2", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index 6ffd95918f4..036a63834e4 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -67,7 +67,7 @@ "@types/react": "18.3.18", "rollup": "^4.22.4", "rollup-plugin-copy": "3.5.0", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "react": ">=18" diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index 1d56eb47a0e..e2fef5480c2 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -36,14 +36,14 @@ "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/jest": "^29.5.4", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/react": "18.3.18", "@types/systemjs": "6.15.3", "jest": "^29.6.4", "react": "18.3.1", - "ts-jest": "29.2.5", + "ts-jest": "29.4.0", "ts-node": "10.9.2", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 3ca7029e86d..c22258285c0 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -9,12 +9,12 @@ "type": "module", "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@swc/core": "1.10.12", + "@swc/core": "1.13.3", "@swc/helpers": "^0.5.0", "@swc/jest": "^0.2.26", "@types/eslint": "9.6.1", "@types/webpack-bundle-analyzer": "^4.7.0", - "copy-webpack-plugin": "12.0.2", + "copy-webpack-plugin": "13.0.0", "eslint": "9.32.0", "eslint-webpack-plugin": "4.2.0", "fork-ts-checker-webpack-plugin": "9.1.0", @@ -25,8 +25,8 @@ "jest-environment-jsdom": "29.7.0", "replace-in-file-webpack-plugin": "1.0.6", "swc-loader": "0.2.6", - "typescript": "5.8.3", - "webpack": "5.97.1", + "typescript": "5.9.2", + "webpack": "5.101.0", "webpack-bundle-analyzer": "^4.10.2", "webpack-virtual-modules": "^0.6.2" }, diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index efd2b239574..8dc8f8a9a78 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "@emotion/css": "11.13.5", - "@floating-ui/react": "0.27.14", + "@floating-ui/react": "0.27.15", "@grafana/data": "12.2.0-pre", "@grafana/e2e-selectors": "12.2.0-pre", "@grafana/i18n": "12.2.0-pre", @@ -87,7 +87,7 @@ "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/jest": "29.5.14", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/pluralize": "^0.0.33", "@types/prismjs": "1.26.5", "esbuild": "0.25.8", @@ -102,7 +102,7 @@ "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", "testing-library-selector": "0.3.1", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx index 4e4c63352f3..1edbf5ba744 100644 --- a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx @@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { select } from 'react-select-event'; -import { dateTime, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { PrometheusDatasource } from '../datasource'; @@ -376,24 +375,4 @@ describe('PromVariableQueryEditor', () => { qryType: 5, }); }); - - test('Calls language provider with the time range received in props', async () => { - const now = dateTime('2023-09-16T21:26:00Z'); - const range: TimeRange = { - from: dateTime(now).subtract(2, 'days'), - to: now, - raw: { - from: 'now-2d', - to: 'now', - }, - }; - props.range = range; - - const languageProviderStartMock = jest.fn(); - props.datasource.languageProvider.start = languageProviderStartMock; - - render(); - - expect(languageProviderStartMock).toHaveBeenCalledWith(range); - }); }); diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx index d8f99a7bc48..2ec81339219 100644 --- a/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx @@ -77,11 +77,6 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: // label filters have been added as a filter for metrics in label values query type const [labelFilters, setLabelFilters] = useState([]); - useEffect(() => { - datasource.languageProvider.start(range); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useEffect(() => { if (!query) { return; diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts index c5fc15d6a8f..73964477bf3 100644 --- a/packages/grafana-prometheus/src/datasource.test.ts +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -19,6 +19,7 @@ import { config, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runt import { extractResourceMatcher, extractRuleMappingFromGroups, PrometheusDatasource } from './datasource'; import { prometheusRegularEscape, prometheusSpecialRegexEscape } from './escaping'; import { PrometheusLanguageProviderInterface } from './language_provider'; +import { CacheRequestInfo } from './querycache/QueryCache'; import { createDataRequest, createDefaultPromResponse, @@ -1253,3 +1254,72 @@ describe('modifyQuery', () => { }); }); }); + +describe('PrometheusDatasource incremental query logic', () => { + let ds: PrometheusDatasource; + let mockCache: { + requestInfo: jest.MockedFunction<(request: DataQueryRequest) => CacheRequestInfo>; + procFrames: jest.MockedFunction<(...args: unknown[]) => unknown[]>; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCache = { + requestInfo: jest.fn().mockReturnValue({ + requests: [{ targets: [], range: getMockTimeRange() }], + targetSignatures: new Map(), + shouldCache: true, + }), + procFrames: jest.fn().mockReturnValue([]), + }; + + const incrementalInstanceSettings = { + url: 'proxied', + id: 1, + uid: 'ABCDEF', + access: 'proxy', + user: 'test', + password: 'mupp', + jsonData: { + customQueryParameters: '', + cacheLevel: PrometheusCacheLevel.Low, + incrementalQuerying: true, + } as Partial, + } as unknown as DataSourceInstanceSettings; + + ds = new PrometheusDatasource(incrementalInstanceSettings, templateSrvStub); + ds.cache = mockCache as unknown as typeof ds.cache; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use incremental query for normal queries when incrementalQuerying is true', async () => { + const request = createDataRequest([{ expr: 'up', refId: 'A' }]); + await lastValueFrom(ds.query(request)); + expect(mockCache.requestInfo).toHaveBeenCalled(); + }); + + it('should disable incremental query when query contains $__range', async () => { + const request = createDataRequest([{ expr: 'rate(up[$__range])', refId: 'A' }]); + await lastValueFrom(ds.query(request)); + expect(mockCache.requestInfo).not.toHaveBeenCalled(); + }); + + it('should disable incremental query when any target contains $__range', async () => { + const request = createDataRequest([ + { expr: 'up', refId: 'A' }, + { expr: 'rate(cpu[$__range])', refId: 'B' }, + ]); + await lastValueFrom(ds.query(request)); + expect(mockCache.requestInfo).not.toHaveBeenCalled(); + }); + + it('should disable incremental query for instant queries', async () => { + const request = createDataRequest([{ expr: 'up', refId: 'A', instant: true }]); + await lastValueFrom(ds.query(request)); + expect(mockCache.requestInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 1cc48500a80..2239b63be54 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -492,16 +492,16 @@ export class PrometheusDatasource return this.directAccessError(); } - let fullOrPartialRequest: DataQueryRequest; - let requestInfo: CacheRequestInfo | undefined = undefined; - const hasInstantQuery = request.targets.some((target) => target.instant); + // Use incremental query only if enabled and no instant queries or no $__range variables + const shouldUseIncrementalQuery = + this.hasIncrementalQuery && !request.targets.some((target) => target.instant || target.expr.includes('$__range')); - // Don't cache instant queries - if (this.hasIncrementalQuery && !hasInstantQuery) { + let fullOrPartialRequest: DataQueryRequest = request; + let requestInfo: CacheRequestInfo | undefined = undefined; + + if (shouldUseIncrementalQuery) { requestInfo = this.cache.requestInfo(request); fullOrPartialRequest = requestInfo.requests[0]; - } else { - fullOrPartialRequest = request; } const targets = fullOrPartialRequest.targets.map((target) => this.processTargetV2(target, fullOrPartialRequest)); diff --git a/packages/grafana-prometheus/src/locales/cs-CZ/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/cs-CZ/grafana-prometheus.json index 3b17ab6a5a8..44a8acbc7a2 100644 --- a/packages/grafana-prometheus/src/locales/cs-CZ/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/cs-CZ/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Filtrovat názvy metrik podle vyhledávání regulárních výrazů pomocí dalšího volání rozhraní Prometheus API.", - "disable-text-wrap": "Zakázat obtékání textu" - }, "feedback-link": { "give-feedback": "Poskytnout zpětnou vazbu", "title-give-feedback": "Průzkumník metrik je nový. Dejte nám prosím vědět, jak ho můžeme vylepšit" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,18 +371,10 @@ "tooltip-metric": "Volitelné: vrátí seznam hodnot štítků pro název štítku v zadané metrice." }, "metrics-modal": { - "additional-settings": "Dodatečná nastavení", - "aria-label-additional-settings": "Další nastavení", "aria-label-browse-metrics": "Procházet metriky", "currently-selected": "Aktuálně vybráno: {{selected}}", "metrics-pre-filtered": "Tyto metriky byly předem filtrovány štítky vybranými ve filtrech štítků.", - "placeholder-results-per-page": "výsledků na stránku", - "results-per-page": "Výsledky na stránku", - "title-metrics-explorer": "Průzkumník metrik", - "results-amount_one": "Zobrazuje se {{num}} z {{count}} výsledků", - "results-amount_few": "Zobrazuje se {{num}} z {{count}} výsledků", - "results-amount_many": "Zobrazuje se {{num}} z {{count}} výsledků", - "results-amount_other": "Zobrazuje se {{num}} z {{count}} výsledků" + "title-metrics-explorer": "Průzkumník metrik" }, "nested-query": { "label": { @@ -502,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Název", - "select": "Vybrat", "type": "Typ" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/de-DE/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/de-DE/grafana-prometheus.json index 7b2db63bb8b..c391ba11f3d 100644 --- a/packages/grafana-prometheus/src/locales/de-DE/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/de-DE/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Filtern Sie Metriknamen per Regex-Suche und nutzen Sie dabei einen zusätzlichen Aufruf der Prometheus-API.", - "disable-text-wrap": "Textumbruch deaktivieren" - }, "feedback-link": { "give-feedback": "Feedback geben", "title-give-feedback": "Der Metrik-Explorer ist neu. Bitte teilen Sie uns mit, wie wir ihn verbessern können." @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "Optional: gibt eine Liste von Label-Werten für den Label-Namen in der angegebenen Metrik zurück." }, "metrics-modal": { - "additional-settings": "Zusätzliche Einstellungen", - "aria-label-additional-settings": "Zusätzliche Einstellungen", "aria-label-browse-metrics": "Metriken durchsuchen", "currently-selected": "Aktuell ausgewählt: {{selected}}", "metrics-pre-filtered": "Diese Metriken wurden anhand der Labels, die in den Label-Filtern ausgewählt wurden, vorgefiltert.", - "placeholder-results-per-page": "Ergebnisse pro Seite", - "results-per-page": "Ergebnisse pro Seite", - "title-metrics-explorer": "Metrik-Explorer", - "results-amount_one": "{{num}} von {{count}} Ergebnissen werden angezeigt", - "results-amount_other": "{{num}} von {{count}} Ergebnissen werden angezeigt" + "title-metrics-explorer": "Metrik-Explorer" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Name", - "select": "Auswählen", "type": "Typ" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json index b94bb4cded3..f67ac0e3e9f 100644 --- a/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Filter metric names by regex search, using an additional call on the Prometheus API.", - "disable-text-wrap": "Disable text wrap" - }, "feedback-link": { "give-feedback": "Give feedback", "title-give-feedback": "The metrics explorer is new, please let us know how we can improve it" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "Search metrics by name", - "include-null-metadata": "Include results with no metadata", - "metadata-search-switch": "Include description in search", - "set-use-backend": "Enable regex search", "type": "Filter by type" }, "get-prom-types": { @@ -378,15 +371,9 @@ "tooltip-metric": "Optional: returns a list of label values for the label name in the specified metric." }, "metrics-modal": { - "additional-settings": "Additional Settings", - "aria-label-additional-settings": "Additional settings", "aria-label-browse-metrics": "Browse metrics", "currently-selected": "Currently selected: {{selected}}", "metrics-pre-filtered": "These metrics have been pre-filtered by labels chosen in the label filters.", - "placeholder-results-per-page": "results per page", - "results-amount_one": "Showing {{num}} of {{count}} results", - "results-amount_other": "Showing {{num}} of {{count}} results", - "results-per-page": "Results per page", "title-metrics-explorer": "Metrics explorer" }, "nested-query": { @@ -500,7 +487,6 @@ "message-expand-search": "There are no metrics found. Try to expand your search and filters.", "message-no-metrics-found": "There are no metrics found in the data source.", "name": "Name", - "select": "Select", "type": "Type" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/es-ES/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/es-ES/grafana-prometheus.json index 8feaf63282c..c0016de4b4a 100644 --- a/packages/grafana-prometheus/src/locales/es-ES/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/es-ES/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Enviar comentarios", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Configuraciones adicionales", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Nombre", - "select": "Seleccionar", "type": "Tipo" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/fr-FR/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/fr-FR/grafana-prometheus.json index 1b48f0ac903..75fffbffa7e 100644 --- a/packages/grafana-prometheus/src/locales/fr-FR/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/fr-FR/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Filtrez les noms de métriques par recherche d’expression régulière, en utilisant un appel supplémentaire sur l’API Prometheus.", - "disable-text-wrap": "Désactiver le retour à la ligne automatique" - }, "feedback-link": { "give-feedback": "Publiez votre commentaire", "title-give-feedback": "L’explorateur de métriques est une nouvelle fonctionnalité ; faites-nous part de vos retours pour l’améliorer" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "Facultatif : renvoie une liste de valeurs d’étiquette pour le nom d’étiquette dans la métrique spécifiée." }, "metrics-modal": { - "additional-settings": "Paramètres additionnels", - "aria-label-additional-settings": "Paramètres supplémentaires", "aria-label-browse-metrics": "Parcourir les métriques", "currently-selected": "Sélection actuelle : {{selected}}", "metrics-pre-filtered": "Ces métriques ont été pré-filtrées par les étiquettes choisies dans les filtres d’étiquettes.", - "placeholder-results-per-page": "résultats par page", - "results-per-page": "Résultats par page", - "title-metrics-explorer": "Explorateur de métriques", - "results-amount_one": "Affichage de {{num}} résultats sur {{count}}", - "results-amount_other": "Affichage de {{num}} résultats sur {{count}}" + "title-metrics-explorer": "Explorateur de métriques" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Nom", - "select": "Sélectionner", "type": "Type" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/hu-HU/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/hu-HU/grafana-prometheus.json index 2b4464c68f6..454e51f3684 100644 --- a/packages/grafana-prometheus/src/locales/hu-HU/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/hu-HU/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "A metrikanevek szűrése reguláris kifejezéses kereséssel, a Prometheus API további hívásával.", - "disable-text-wrap": "Sortörés letiltása" - }, "feedback-link": { "give-feedback": "Visszajelzés küldése", "title-give-feedback": "A metrikaböngésző új, és számítunk az építő jellegű visszajelzésére" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "Opcionális: a megadott metrikában a címke nevéhez tartozó címkeértékek listáját adja vissza." }, "metrics-modal": { - "additional-settings": "További beállítások", - "aria-label-additional-settings": "További beállítások", "aria-label-browse-metrics": "Metrikák böngészése", "currently-selected": "Jelenleg kiválasztott: {{selected}}", "metrics-pre-filtered": "Ezek a metrikák előszűrtek a címkeszűrőkben kiválasztott címkék alapján.", - "placeholder-results-per-page": "találat oldalanként", - "results-per-page": "Találatok száma oldalanként", - "title-metrics-explorer": "Metrikaböngésző", - "results-amount_one": "{{count}}/{{num}} találat megjelenítése", - "results-amount_other": "{{count}}/{{num}} találat megjelenítése" + "title-metrics-explorer": "Metrikaböngésző" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Név", - "select": "Kijelölés", "type": "Típus" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/id-ID/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/id-ID/grafana-prometheus.json index bbcd6d32f7e..e674183019f 100644 --- a/packages/grafana-prometheus/src/locales/id-ID/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/id-ID/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Filter nama metrik dengan pencarian regex, menggunakan panggilan tambahan pada API Prometheus.", - "disable-text-wrap": "Nonaktifkan pemenggalan teks otomatis" - }, "feedback-link": { "give-feedback": "Berikan umpan balik", "title-give-feedback": "Penjelajah metrik ini masih baru, beri tahu kami cara untuk meningkatkannya" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,15 +371,10 @@ "tooltip-metric": "Opsional: mengembalikan daftar nilai label untuk nama label dalam metrik yang ditentukan." }, "metrics-modal": { - "additional-settings": "Pengaturan Tambahan", - "aria-label-additional-settings": "Pengaturan tambahan", "aria-label-browse-metrics": "Telusuri metrik", "currently-selected": "Sedang dipilih: {{selected}}", "metrics-pre-filtered": "Metrik ini telah difilter sebelumnya berdasarkan label yang dipilih di filter label.", - "placeholder-results-per-page": "hasil per halaman", - "results-per-page": "Hasil per halaman", - "title-metrics-explorer": "Penjelajah metrik", - "results-amount_other": "Menampilkan {{num}} dari {{count}} hasil" + "title-metrics-explorer": "Penjelajah metrik" }, "nested-query": { "label": { @@ -499,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Nama", - "select": "Pilih", "type": "Jenis" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/it-IT/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/it-IT/grafana-prometheus.json index 9cfc70e6c88..2671478ca0f 100644 --- a/packages/grafana-prometheus/src/locales/it-IT/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/it-IT/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Lascia un feedback", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Impostazioni aggiuntive", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Nome", - "select": "Seleziona", "type": "Tipo" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/ja-JP/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/ja-JP/grafana-prometheus.json index c4f13c7d462..72b1785b222 100644 --- a/packages/grafana-prometheus/src/locales/ja-JP/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/ja-JP/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Prometheus APIの追加呼び出しを使用して、正規表現検索でメトリック名をフィルタリングします。", - "disable-text-wrap": "テキストの折り返しを無効化" - }, "feedback-link": { "give-feedback": "フィードバックを送信", "title-give-feedback": "メトリックエクスプローラーは新機能です。改善点についてお聞かせください" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,15 +371,10 @@ "tooltip-metric": "オプション: 指定されたメトリックのラベル名に対するラベル値のリストを返します。" }, "metrics-modal": { - "additional-settings": "追加設定", - "aria-label-additional-settings": "追加設定", "aria-label-browse-metrics": "メトリックを参照", "currently-selected": "現在選択中: {{selected}}", "metrics-pre-filtered": "これらのメトリックは、ラベルフィルターで選択されたラベルによって事前にフィルタリングされています。", - "placeholder-results-per-page": "1ページあたりの結果数", - "results-per-page": "1ページあたりの結果数", - "title-metrics-explorer": "メトリックエクスプローラー", - "results-amount_other": "{{count}}件中{{num}}件の結果を表示" + "title-metrics-explorer": "メトリックエクスプローラー" }, "nested-query": { "label": { @@ -499,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "名前", - "select": "選択", "type": "種類" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/ko-KR/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/ko-KR/grafana-prometheus.json index f9824a261d9..975a9631f32 100644 --- a/packages/grafana-prometheus/src/locales/ko-KR/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/ko-KR/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "피드백 제출하기", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,15 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "추가 설정", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -499,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "이름", - "select": "선택", "type": "유형" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/nl-NL/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/nl-NL/grafana-prometheus.json index 9a1280a547c..f20146043d3 100644 --- a/packages/grafana-prometheus/src/locales/nl-NL/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/nl-NL/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "Filter metrische namen op regex-zoekopdracht, met behulp van een extra oproep op de Prometheus-API.", - "disable-text-wrap": "Tekstterugloop uitschakelen" - }, "feedback-link": { "give-feedback": "Feedback geven", "title-give-feedback": "De metriekverkenner is nieuw, laat ons weten hoe we deze kunnen verbeteren" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "Optioneel: retourneert een lijst met labelwaarden voor de labelnaam in de opgegeven metriek." }, "metrics-modal": { - "additional-settings": "Extra instellingen", - "aria-label-additional-settings": "Aanvullende instellingen", "aria-label-browse-metrics": "Metriek bekijken", "currently-selected": "Momenteel geselecteerd: {{selected}}", "metrics-pre-filtered": "Deze metriek is vooraf gefilterd op labels die zijn gekozen in de labelfilters.", - "placeholder-results-per-page": "resultaten per pagina", - "results-per-page": "Resultaten per pagina", - "title-metrics-explorer": "Metriekverkenner", - "results-amount_one": "{{num}} resultaten van {{count}} weergeven", - "results-amount_other": "{{num}} resultaten van {{count}} weergeven" + "title-metrics-explorer": "Metriekverkenner" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Naam", - "select": "Selecteren", "type": "Type" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/pl-PL/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/pl-PL/grafana-prometheus.json index a52ae2f32a7..fe3211ff5e4 100644 --- a/packages/grafana-prometheus/src/locales/pl-PL/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/pl-PL/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Przekaż opinię", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,18 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Ustawienia dodatkowe", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_few": "", - "results-amount_many": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -502,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Imię", - "select": "Wybierz", "type": "Typ" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/pt-BR/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/pt-BR/grafana-prometheus.json index cf886e70733..507ddf3c792 100644 --- a/packages/grafana-prometheus/src/locales/pt-BR/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/pt-BR/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Dar feedback", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Configurações adicionais", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Nome", - "select": "Selecionar", "type": "Tipo" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/pt-PT/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/pt-PT/grafana-prometheus.json index b6d796a828c..3f80c60195a 100644 --- a/packages/grafana-prometheus/src/locales/pt-PT/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/pt-PT/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Dar feedback", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Definições adicionais", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Nome", - "select": "Selecionar", "type": "Tipo" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/ru-RU/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/ru-RU/grafana-prometheus.json index 473f1f146f7..896c67e458f 100644 --- a/packages/grafana-prometheus/src/locales/ru-RU/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/ru-RU/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Отправить отзыв", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,18 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Дополнительные параметры", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_few": "", - "results-amount_many": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -502,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Имя", - "select": "Выбрать", "type": "Тип" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/sv-SE/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/sv-SE/grafana-prometheus.json index 017e25b5ae3..f51290dffe1 100644 --- a/packages/grafana-prometheus/src/locales/sv-SE/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/sv-SE/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Ge feedback", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Ytterligare inställningar", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Namn", - "select": "Välj", "type": "Typ" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/tr-TR/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/tr-TR/grafana-prometheus.json index 10e63ea4441..9aa271d54fe 100644 --- a/packages/grafana-prometheus/src/locales/tr-TR/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/tr-TR/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "", - "disable-text-wrap": "" - }, "feedback-link": { "give-feedback": "Geri bildirim gönder", "title-give-feedback": "" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,16 +371,10 @@ "tooltip-metric": "" }, "metrics-modal": { - "additional-settings": "", - "aria-label-additional-settings": "Ek ayarlar", "aria-label-browse-metrics": "", "currently-selected": "", "metrics-pre-filtered": "", - "placeholder-results-per-page": "", - "results-per-page": "", - "title-metrics-explorer": "", - "results-amount_one": "", - "results-amount_other": "" + "title-metrics-explorer": "" }, "nested-query": { "label": { @@ -500,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "Ad", - "select": "Seçin", "type": "Tür" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/zh-Hans/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/zh-Hans/grafana-prometheus.json index 4050d264fa2..f15a039030c 100644 --- a/packages/grafana-prometheus/src/locales/zh-Hans/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/zh-Hans/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "使用 Prometheus API 上的其他调用,通过正则表达式搜索筛选指标名称。", - "disable-text-wrap": "禁用文本换行" - }, "feedback-link": { "give-feedback": "提供反馈", "title-give-feedback": "指标浏览器是新功能,请告诉我们如何改进" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,15 +371,10 @@ "tooltip-metric": "可选:返回指定指标中标签名称的标签值列表。" }, "metrics-modal": { - "additional-settings": "其他设置", - "aria-label-additional-settings": "附加设置", "aria-label-browse-metrics": "浏览指标", "currently-selected": "当前选择:{{selected}}", "metrics-pre-filtered": "这些指标已通过标签筛选器中选择的标签进行预筛选。", - "placeholder-results-per-page": "每页显示结果数", - "results-per-page": "每页结果数", - "title-metrics-explorer": "指标浏览器", - "results-amount_other": "显示 {{num}} 个结果(共 {{count}} 个)" + "title-metrics-explorer": "指标浏览器" }, "nested-query": { "label": { @@ -499,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "名称", - "select": "选择", "type": "类型" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/locales/zh-Hant/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/zh-Hant/grafana-prometheus.json index 33c4d871bb0..d6a441a81bf 100644 --- a/packages/grafana-prometheus/src/locales/zh-Hant/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/zh-Hant/grafana-prometheus.json @@ -311,10 +311,6 @@ } }, "querybuilder": { - "additional-settings": { - "content-filter-metric-names-regex-search-using": "透過正規表達式搜尋篩選指標名稱,並使用額外的 Prometheus API 呼叫。", - "disable-text-wrap": "停用文字換行" - }, "feedback-link": { "give-feedback": "提供意見回饋", "title-give-feedback": "指標瀏覽器為新功能,請告訴我們如何改善" @@ -328,9 +324,6 @@ }, "get-placeholders": { "browse": "", - "include-null-metadata": "", - "metadata-search-switch": "", - "set-use-backend": "", "type": "" }, "get-prom-types": { @@ -378,15 +371,10 @@ "tooltip-metric": "選擇性設定:回傳指定指標中該標籤名稱的標籤值清單。" }, "metrics-modal": { - "additional-settings": "附加設定", - "aria-label-additional-settings": "附加設定", "aria-label-browse-metrics": "瀏覽指標", "currently-selected": "目前已選取:{{selected}}", "metrics-pre-filtered": "這些指標已透過標籤篩選條件中選擇的標籤進行預先篩選。", - "placeholder-results-per-page": "每頁顯示結果", - "results-per-page": "每頁顯示的結果", - "title-metrics-explorer": "指標瀏覽器", - "results-amount_other": "結果共 {{count}} 項,顯示 {{num}} 項" + "title-metrics-explorer": "指標瀏覽器" }, "nested-query": { "label": { @@ -499,7 +487,6 @@ "message-expand-search": "", "message-no-metrics-found": "", "name": "名稱(名字)", - "select": "選取", "type": "類型" }, "update-function-args": { diff --git a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx index 21a88427159..eaafcb1aa68 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx @@ -11,9 +11,8 @@ import { PrometheusDatasource } from '../../datasource'; import { QueryBuilderLabelFilter } from '../shared/types'; import { PromVisualQuery } from '../types'; +import { formatKeyValueStrings } from './formatter'; import { MetricsModal } from './metrics-modal/MetricsModal'; -import { tracking } from './metrics-modal/state/helpers'; -import { formatKeyValueStrings } from './shared/formatter'; export interface MetricComboboxProps { metricLookupDisabled: boolean; @@ -76,18 +75,6 @@ export function MetricCombobox({ [getMetricLabels, onGetMetrics] ); - const loadMetricsExplorerMetrics = useCallback(async () => { - const allMetrics = await onGetMetrics(); - const metrics: string[] = []; - for (const metric of allMetrics) { - if (metric.value) { - metrics.push(metric.value); - } - } - - return metrics; - }, [onGetMetrics]); - const asyncSelect = () => { return ( @@ -115,10 +102,7 @@ export function MetricCombobox({ )} variant="secondary" icon="book-open" - onClick={() => { - tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query); - setMetricsModalOpen(true); - }} + onClick={() => setMetricsModalOpen(true)} /> ); @@ -133,7 +117,6 @@ export function MetricCombobox({ onClose={() => setMetricsModalOpen(false)} query={query} onChange={onChange} - initialMetrics={loadMetricsExplorerMetrics} timeRange={timeRange} /> )} diff --git a/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx b/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx index 8ac17b1b854..daa84a83c62 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx @@ -11,7 +11,7 @@ import { PrometheusDatasource } from '../../datasource'; import { binaryScalarDefs } from '../binaryScalarOperations'; import { PromVisualQueryBinary } from '../types'; -import { QueryBuilderContent } from './shared/QueryBuilderContent'; +import { QueryBuilderContent } from './QueryBuilderContent'; interface NestedQueryProps { nestedQuery: PromVisualQueryBinary; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx index 03bf97718bc..9504e50b5c0 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx @@ -6,7 +6,8 @@ import { PanelData } from '@grafana/data'; import { PrometheusDatasource } from '../../datasource'; import { PromVisualQuery } from '../types'; -import { BaseQueryBuilder } from './shared/BaseQueryBuilder'; +import { NestedQueryList } from './NestedQueryList'; +import { QueryBuilderContent } from './QueryBuilderContent'; interface PromQueryBuilderProps { query: PromVisualQuery; @@ -18,7 +19,22 @@ interface PromQueryBuilderProps { } export const PromQueryBuilder = memo((props) => { - return ; + const { query, datasource, onChange, onRunQuery, showExplain } = props; + + return ( + <> + + {query.binaryQueries && query.binaryQueries.length > 0 && ( + + )} + + ); }); PromQueryBuilder.displayName = 'PromQueryBuilder'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx index b325da19d45..3e400f13a50 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx @@ -20,12 +20,8 @@ describe('PromQueryBuilderContainer', () => { await addOperationInQueryBuilder('Range functions', 'Rate'); // extra fields here are for storing metrics explorer settings. Future work: store these in local storage. expect(props.onChange).toHaveBeenCalledWith({ - disableTextWrap: false, expr: 'rate(metric_test{job="testjob"}[$__rate_interval])', - fullMetaSearch: false, - includeNullMetadata: true, refId: 'A', - useBackend: false, }); }); diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx index 318d4a71213..31d3b6a6274 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx @@ -1,6 +1,5 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { useEffect, useReducer } from 'react'; +import { useEffect, useState } from 'react'; import { PanelData } from '@grafana/data'; @@ -12,7 +11,6 @@ import { PromVisualQuery } from '../types'; import { PromQueryBuilder } from './PromQueryBuilder'; import { QueryPreview } from './QueryPreview'; -import { getSettings, MetricsModalSettings } from './metrics-modal/state/state'; interface PromQueryBuilderContainerProps { query: PromQuery; @@ -23,7 +21,7 @@ interface PromQueryBuilderContainerProps { showExplain: boolean; } -interface State { +interface RenderedQuery { visQuery?: PromVisualQuery; expr: string; } @@ -33,80 +31,38 @@ interface State { */ export function PromQueryBuilderContainer(props: PromQueryBuilderContainerProps) { const { query, onChange, onRunQuery, datasource, data, showExplain } = props; - const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr }); - // Only rebuild visual query if expr changes from outside - useEffect(() => { - dispatch(exprChanged(query.expr)); - dispatch( - setMetricsModalSettings({ - useBackend: query.useBackend ?? false, - disableTextWrap: query.disableTextWrap ?? false, - fullMetaSearch: query.fullMetaSearch ?? false, - includeNullMetadata: query.includeNullMetadata ?? true, - }) - ); - }, [query]); + const [rendered, setRendered] = useState({ expr: query.expr }); useEffect(() => { - datasource.languageProvider.start(data?.timeRange); - }, [data?.timeRange, datasource.languageProvider]); + // Only rebuild visual query if expr changes from outside + if (!rendered.visQuery || rendered.expr !== query.expr) { + const parseResult = buildVisualQueryFromString(query.expr ?? ''); + + setRendered({ expr: query.expr, visQuery: parseResult.query }); + } + }, [query, rendered]); const onVisQueryChange = (visQuery: PromVisualQuery) => { const expr = promQueryModeller.renderQuery(visQuery); - dispatch(visualQueryChange({ visQuery, expr })); - - const metricsModalSettings = getSettings(visQuery); - onChange({ ...props.query, expr: expr, ...metricsModalSettings }); + setRendered({ expr, visQuery }); + onChange({ ...props.query, expr }); }; - if (!state.visQuery) { + if (!rendered.visQuery) { return null; } return ( <> - {} + ); } - -const initialState: State = { - expr: '', -}; - -const stateSlice = createSlice({ - name: 'prom-builder-container', - initialState, - 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; - } - }, - setMetricsModalSettings: (state, action: PayloadAction) => { - if (state.visQuery) { - state.visQuery.useBackend = action.payload.useBackend; - state.visQuery.disableTextWrap = action.payload.disableTextWrap; - state.visQuery.fullMetaSearch = action.payload.fullMetaSearch; - state.visQuery.includeNullMetadata = action.payload.includeNullMetadata; - } - }, - }, -}); - -const { visualQueryChange, exprChanged, setMetricsModalSettings } = stateSlice.actions; diff --git a/packages/grafana-prometheus/src/querybuilder/components/shared/QueryBuilderContent.tsx b/packages/grafana-prometheus/src/querybuilder/components/QueryBuilderContent.tsx similarity index 67% rename from packages/grafana-prometheus/src/querybuilder/components/shared/QueryBuilderContent.tsx rename to packages/grafana-prometheus/src/querybuilder/components/QueryBuilderContent.tsx index ce00f36cbb9..b04537da808 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/shared/QueryBuilderContent.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/QueryBuilderContent.tsx @@ -1,28 +1,37 @@ import { css } from '@emotion/css'; import { memo, useState } from 'react'; -import { DataSourceApi, getDefaultTimeRange } from '@grafana/data'; +import { DataSourceApi, getDefaultTimeRange, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { EditorRow } from '@grafana/plugin-ui'; -import { promqlGrammar } from '../../../promql'; -import { getInitHints } from '../../../query_hints'; -import { buildVisualQueryFromString } from '../../parsing'; -import { OperationExplainedBox } from '../../shared/OperationExplainedBox'; -import { OperationList } from '../../shared/OperationList'; -import { OperationListExplained } from '../../shared/OperationListExplained'; -import { OperationsEditorRow } from '../../shared/OperationsEditorRow'; -import { QueryBuilderHints } from '../../shared/QueryBuilderHints'; -import { RawQuery } from '../../shared/RawQuery'; -import { promQueryModeller } from '../../shared/modeller_instance'; -import { QueryBuilderOperation } from '../../shared/types'; -import { PromVisualQuery } from '../../types'; -import { MetricsLabelsSection } from '../MetricsLabelsSection'; -import { EXPLAIN_LABEL_FILTER_CONTENT } from '../PromQueryBuilderExplained'; +import { PrometheusDatasource } from '../../datasource'; +import { promqlGrammar } from '../../promql'; +import { getInitHints } from '../../query_hints'; +import { buildVisualQueryFromString } from '../parsing'; +import { OperationExplainedBox } from '../shared/OperationExplainedBox'; +import { OperationList } from '../shared/OperationList'; +import { OperationListExplained } from '../shared/OperationListExplained'; +import { OperationsEditorRow } from '../shared/OperationsEditorRow'; +import { QueryBuilderHints } from '../shared/QueryBuilderHints'; +import { RawQuery } from '../shared/RawQuery'; +import { promQueryModeller } from '../shared/modeller_instance'; +import { QueryBuilderOperation } from '../shared/types'; +import { PromVisualQuery } from '../types'; -import { BaseQueryBuilderProps } from './types'; +import { MetricsLabelsSection } from './MetricsLabelsSection'; +import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained'; -export const QueryBuilderContent = memo((props) => { +interface QueryBuilderContentProps { + query: PromVisualQuery; + datasource: PrometheusDatasource; + onChange: (update: PromVisualQuery) => void; + onRunQuery: () => void; + data?: PanelData; + showExplain: boolean; +} + +export const QueryBuilderContent = memo((props) => { const { datasource, query, onChange, onRunQuery, data, showExplain } = props; const [highlightedOp, setHighlightedOp] = useState(); diff --git a/packages/grafana-prometheus/src/querybuilder/components/shared/formatter.ts b/packages/grafana-prometheus/src/querybuilder/components/formatter.ts similarity index 81% rename from packages/grafana-prometheus/src/querybuilder/components/shared/formatter.ts rename to packages/grafana-prometheus/src/querybuilder/components/formatter.ts index baf8228c573..bb795745816 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/shared/formatter.ts +++ b/packages/grafana-prometheus/src/querybuilder/components/formatter.ts @@ -1,7 +1,7 @@ -import { regexifyLabelValuesQueryString } from '../../parsingUtils'; -import { QueryBuilderLabelFilter } from '../../shared/types'; +import { regexifyLabelValuesQueryString } from '../parsingUtils'; +import { QueryBuilderLabelFilter } from '../shared/types'; -export const formatPrometheusLabelFiltersToString = ( +const formatPrometheusLabelFiltersToString = ( queryString: string, labelsFilters: QueryBuilderLabelFilter[] | undefined ): string => { diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx deleted file mode 100644 index 4ce04117dd9..00000000000 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { Icon, Switch, Tooltip, useTheme2 } from '@grafana/ui'; - -import { metricsModaltestIds } from './shared/testIds'; -import { AdditionalSettingsProps } from './shared/types'; -import { getPlaceholders } from './state/helpers'; - -export function AdditionalSettings(props: AdditionalSettingsProps) { - const { state, onChangeFullMetaSearch, onChangeIncludeNullMetadata, onChangeDisableTextWrap, onChangeUseBackend } = - props; - - const theme = useTheme2(); - const styles = getStyles(theme); - - const placeholders = getPlaceholders(); - - return ( - <> -
- onChangeFullMetaSearch()} - /> -
{placeholders.metadataSearchSwitch}
-
-
- onChangeIncludeNullMetadata()} - /> -
{placeholders.includeNullMetadata}
-
-
- onChangeDisableTextWrap()} /> -
- - Disable text wrap - -
-
-
- onChangeUseBackend()} - /> -
{placeholders.setUseBackend} 
- - - -
- - ); -} - -function getStyles(theme: GrafanaTheme2) { - return { - settingsIcon: css({ - color: theme.colors.text.secondary, - }), - selectItem: css({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - padding: '4px 0', - }), - selectItemLabel: css({ - margin: `0 0 0 ${theme.spacing(1)}`, - alignSelf: 'center', - color: theme.colors.text.secondary, - fontSize: '12px', - }), - }; -} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx index c85089657cb..f57fbb59bd6 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -12,7 +12,7 @@ import { PromOptions } from '../../../types'; import { PromVisualQuery } from '../../types'; import { MetricsModal } from './MetricsModal'; -import { metricsModaltestIds } from './shared/testIds'; +import { metricsModaltestIds } from './testIds'; // don't care about interaction tracking in our unit tests jest.mock('@grafana/runtime', () => ({ @@ -117,28 +117,6 @@ describe('MetricsModal', () => { }); }); - it('shows results metrics per page chosen by the user', async () => { - setup(defaultQuery, listOfMetrics); - const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage); - await userEvent.type(resultsPerPageInput, '12'); - const metricInsideRange = screen.getByText('j'); - expect(metricInsideRange).toBeInTheDocument(); - }); - - it('paginates lots of metrics and does not run out of memory', async () => { - const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i); - setup(defaultQuery, lotsOfMetrics); - await waitFor(() => { - // doesn't break on loading - expect(screen.getByText('0')).toBeInTheDocument(); - }); - const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage); - // doesn't break on changing results per page - await userEvent.type(resultsPerPageInput, '11'); - const metricInsideRange = screen.getByText('9'); - expect(metricInsideRange).toBeInTheDocument(); - }); - // Fuzzy search it('searches and filter by metric name with a fuzzy search', async () => { // search for a_bucket by name @@ -171,14 +149,6 @@ describe('MetricsModal', () => { expect(metricABucket).toBeInTheDocument(); }); - const showSettingsButton = screen.getByTestId(metricsModaltestIds.showAdditionalSettings); - expect(showSettingsButton).toBeInTheDocument(); - await userEvent.click(showSettingsButton); - - const metadataSwitch = screen.getByTestId(metricsModaltestIds.searchWithMetadata); - expect(metadataSwitch).toBeInTheDocument(); - await userEvent.click(metadataSwitch); - const searchMetric = screen.getByTestId(metricsModaltestIds.searchMetric); expect(searchMetric).toBeInTheDocument(); await userEvent.type(searchMetric, 'functions'); @@ -248,6 +218,34 @@ function createDatasource(withLabels?: boolean) { const languageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface; // display different results if their labels are selected in the PromVisualQuery + if (withLabels) { + languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({ + 'with-labels': { + type: 'with-labels-type', + help: 'with-labels-help', + }, + }); + } else { + // all metrics - create metadata for all metrics in listOfMetrics + const mockMetadata: Record = {}; + listOfMetrics.forEach((metric) => { + if (metric === 'all-metrics') { + mockMetadata[metric] = { type: 'all-metrics-type', help: 'all-metrics-help' }; + } else if (metric === 'a_bucket') { + mockMetadata[metric] = { type: 'histogram', help: 'for functions' }; + } else if (metric === 'new_histogram') { + mockMetadata[metric] = { type: 'histogram', help: 'a native histogram' }; + } else if (metric === 'a') { + mockMetadata[metric] = { type: 'counter', help: 'a-metric-help' }; + } else { + mockMetadata[metric] = { type: 'counter', help: `${metric} metric help` }; + } + }); + + languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue(mockMetadata); + } + + // Also mock the retrieveMetricsMetadata method that might be used elsewhere if (withLabels) { languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({ 'with-labels': { @@ -256,26 +254,23 @@ function createDatasource(withLabels?: boolean) { }, }); } else { - // all metrics - languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({ - 'all-metrics': { - type: 'all-metrics-type', - help: 'all-metrics-help', - }, - a: { - type: 'counter', - help: 'a-metric-help', - }, - a_bucket: { - type: 'histogram', - help: 'for functions', - }, - new_histogram: { - type: 'histogram', - help: 'a native histogram', - }, - // missing metadata for other metrics is tested for, see below + // Create the same metadata structure for retrieveMetricsMetadata + const mockMetadata: Record = {}; + listOfMetrics.forEach((metric) => { + if (metric === 'all-metrics') { + mockMetadata[metric] = { type: 'all-metrics-type', help: 'all-metrics-help' }; + } else if (metric === 'a_bucket') { + mockMetadata[metric] = { type: 'histogram', help: 'for functions' }; + } else if (metric === 'new_histogram') { + mockMetadata[metric] = { type: 'histogram', help: 'a native histogram' }; + } else if (metric === 'a') { + mockMetadata[metric] = { type: 'counter', help: 'a-metric-help' }; + } else { + mockMetadata[metric] = { type: 'counter', help: `${metric} metric help` }; + } }); + + languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue(mockMetadata); } const datasource = new PrometheusDatasource( diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx index 0722489b771..59c4c703ccf 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -1,107 +1,49 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx import { cx } from '@emotion/css'; -import debounce from 'debounce-promise'; -import { useCallback, useEffect, useMemo, useReducer } from 'react'; -import { SelectableValue } from '@grafana/data'; +import { SelectableValue, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; -import { - Button, - ButtonGroup, - Icon, - Input, - Modal, - MultiSelect, - Pagination, - Spinner, - Toggletip, - useTheme2, -} from '@grafana/ui'; +import { Icon, Input, Modal, MultiSelect, Pagination, Spinner, useStyles2 } from '@grafana/ui'; -import { getDebounceTimeInMilliseconds } from '../../../caching'; -import { METRIC_LABEL } from '../../../constants'; -import { regexifyLabelValuesQueryString } from '../../parsingUtils'; -import { formatPrometheusLabelFilters } from '../shared/formatter'; +import { PrometheusDatasource } from '../../../datasource'; +import { PromVisualQuery } from '../../types'; -import { AdditionalSettings } from './AdditionalSettings'; import { FeedbackLink } from './FeedbackLink'; +import { MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext'; import { ResultsTable } from './ResultsTable'; -import { metricsModaltestIds } from './shared/testIds'; -import { MetricsModalProps } from './shared/types'; -import { - calculatePageList, - calculateResultsPerPage, - displayedMetrics, - getPlaceholders, - getPromTypes, - setMetrics, - tracking, -} from './state/helpers'; -import { - buildMetrics, - DEFAULT_RESULTS_PER_PAGE, - filterMetricsBackend, - initialState, - MAXIMUM_RESULTS_PER_PAGE, - MetricsModalMetadata, - setDisableTextWrap, - setFullMetaSearch, - setFuzzySearchQuery, - setIncludeNullMetadata, - setIsLoading, - setMetaHaystack, - setNameHaystack, - setPageNum, - setResultsPerPage, - setSelectedTypes, - setUseBackend, - showAdditionalSettings, - stateSlice, -} from './state/state'; -import { getStyles } from './styles'; +import { getPlaceholders, getPromTypes } from './helpers'; +import { getMetricsModalStyles } from './styles'; +import { metricsModaltestIds } from './testIds'; import { PromFilterOption } from './types'; -import { debouncedFuzzySearch } from './uFuzzy'; -export const MetricsModal = (props: MetricsModalProps) => { - const { datasource, isOpen, onClose, onChange, query, initialMetrics, timeRange } = props; +interface MetricsModalProps { + datasource: PrometheusDatasource; + timeRange: TimeRange; + isOpen: boolean; + query: PromVisualQuery; + onClose: () => void; + onChange: (query: PromVisualQuery) => void; +} - const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query)); +const MetricsModalContent = (props: MetricsModalProps) => { + const { isOpen, onClose, onChange, query, timeRange } = props; - const theme = useTheme2(); - const styles = getStyles(theme, state.disableTextWrap); + const { + isLoading, + filteredMetricsData, + debouncedBackendSearch, + pagination, + setPagination, + selectedTypes, + setSelectedTypes, + searchedText, + setSearchedText, + } = useMetricsModal(); + const styles = useStyles2(getMetricsModalStyles); const placeholders = getPlaceholders(); const promTypes = getPromTypes(); - /** - * loads metrics and metadata on opening modal and switching off useBackend - */ - const updateMetricsMetadata = useCallback(async () => { - // *** Loading Gif - dispatch(setIsLoading(true)); - - // Because Combobox in MetricsCombobox doesn't use the same lifecycle as Select to open the Metrics Explorer - // it might not have loaded any metrics yet, so it instead passes in an async function to get the metrics - const metrics = typeof initialMetrics === 'function' ? await initialMetrics() : initialMetrics; - - const data: MetricsModalMetadata = await setMetrics(datasource, query, metrics); - dispatch( - buildMetrics({ - isLoading: false, - hasMetadata: data.hasMetadata, - metrics: data.metrics, - metaHaystackDictionary: data.metaHaystackDictionary, - nameHaystackDictionary: data.nameHaystackDictionary, - totalMetricCount: data.metrics.length, - filteredMetricCount: data.metrics.length, - }) - ); - }, [query, datasource, initialMetrics]); - - useEffect(() => { - updateMetricsMetadata(); - }, [updateMetricsMetadata]); - const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => { return { value: t.value, @@ -110,96 +52,10 @@ export const MetricsModal = (props: MetricsModalProps) => { }; }); - /** - * The backend debounced search - */ - const debouncedBackendSearch = useMemo( - () => - debounce(async (metricText: string) => { - dispatch(setIsLoading(true)); - - const queryString = regexifyLabelValuesQueryString(metricText); - const filterArray = query.labels ? formatPrometheusLabelFilters(query.labels) : []; - const match = `{__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}}`; - - const results = await datasource.languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match); - - const resultsOptions = results.map((result) => ({ - value: result, - })); - - dispatch( - filterMetricsBackend({ - metrics: resultsOptions, - filteredMetricCount: resultsOptions.length, - isLoading: false, - }) - ); - }, getDebounceTimeInMilliseconds(datasource.cacheLevel)), - [datasource.cacheLevel, datasource.languageProvider, query.labels, timeRange] - ); - - function fuzzyNameDispatch(haystackData: string[][]) { - dispatch(setNameHaystack(haystackData)); - } - - function fuzzyMetaDispatch(haystackData: string[][]) { - dispatch(setMetaHaystack(haystackData)); - } - - function searchCallback(query: string, fullMetaSearchVal: boolean) { - if (state.useBackend && query === '') { - // get all metrics data if a user erases everything in the input - updateMetricsMetadata(); - } else if (state.useBackend) { - debouncedBackendSearch(query); - } else { - // search either the names or all metadata - // fuzzy search go! - if (fullMetaSearchVal) { - debouncedFuzzySearch(Object.keys(state.metaHaystackDictionary), query, fuzzyMetaDispatch); - } else { - debouncedFuzzySearch(Object.keys(state.nameHaystackDictionary), query, fuzzyNameDispatch); - } - } - } - - /* Settings switches */ - const additionalSettings = ( - { - const newVal = !state.fullMetaSearch; - dispatch(setFullMetaSearch(newVal)); - onChange({ ...query, fullMetaSearch: newVal }); - searchCallback(state.fuzzySearchQuery, newVal); - }} - onChangeIncludeNullMetadata={() => { - dispatch(setIncludeNullMetadata(!state.includeNullMetadata)); - onChange({ ...query, includeNullMetadata: !state.includeNullMetadata }); - }} - onChangeDisableTextWrap={() => { - dispatch(setDisableTextWrap()); - onChange({ ...query, disableTextWrap: !state.disableTextWrap }); - tracking('grafana_prom_metric_encycopedia_disable_text_wrap_interaction', state, ''); - }} - onChangeUseBackend={() => { - const newVal = !state.useBackend; - dispatch(setUseBackend(newVal)); - onChange({ ...query, useBackend: newVal }); - if (newVal === false) { - // rebuild the metrics metadata if we turn off useBackend - updateMetricsMetadata(); - } else { - // check if there is text in the browse search and update - if (state.fuzzySearchQuery !== '') { - debouncedBackendSearch(state.fuzzySearchQuery); - } - // otherwise wait for user typing - } - }} - /> - ); + const searchCallback = (query: string, fullMetaSearchVal?: boolean) => { + setSearchedText(query); + debouncedBackendSearch(timeRange, query); + }; return ( { autoFocus={true} data-testid={metricsModaltestIds.searchMetric} placeholder={placeholders.browse} - value={state.fuzzySearchQuery} + value={searchedText} onInput={(e) => { const value = e.currentTarget.value ?? ''; - dispatch(setFuzzySearchQuery(value)); - searchCallback(value, state.fullMetaSearch); + setSearchedText(value); + setPagination({ ...pagination, pageNum: 1 }); + searchCallback(value); }} /> - {state.hasMetadata && ( -
- dispatch(setSelectedTypes(v))} - /> -
- )} -
- -
- - - -
+
+
@@ -298,61 +123,23 @@ export const MetricsModal = (props: MetricsModalProps) => { )}
- {state.metrics && ( - - )} + {filteredMetricsData && }
-
- - Showing {'{{num}}'} of {'{{count}}'} results - -
{ - const page = val ?? 1; - dispatch(setPageNum(page)); - }} + currentPage={pagination.pageNum > pagination.totalPageNum ? 1 : pagination.pageNum} + numberOfPages={pagination.totalPageNum} + onNavigate={(val: number) => setPagination({ ...pagination, pageNum: val ?? 1 })} /> -
-

- Results per page -

- { - const value = +e.currentTarget.value; - - if (isNaN(value) || value >= MAXIMUM_RESULTS_PER_PAGE) { - return; - } - - dispatch(setResultsPerPage(value)); - }} - /> -
); }; + +export const MetricsModal = (props: MetricsModalProps) => { + return ( + + + + ); +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx new file mode 100644 index 00000000000..955b2c1b585 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx @@ -0,0 +1,329 @@ +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; + +import { TimeRange } from '@grafana/data'; + +import { PrometheusLanguageProviderInterface } from '../../../language_provider'; + +import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext'; +import { generateMetricData } from './helpers'; + +// Mock dependencies +jest.mock('./helpers', () => ({ + generateMetricData: jest.fn(), +})); + +const mockGenerateMetricData = generateMetricData as jest.MockedFunction; + +// Mock language provider +const mockLanguageProvider: PrometheusLanguageProviderInterface = { + queryMetricsMetadata: jest.fn(), + queryLabelValues: jest.fn(), + retrieveMetricsMetadata: jest.fn(), +} as unknown as PrometheusLanguageProviderInterface; + +// Helper to create wrapper component +const createWrapper = (languageProvider = mockLanguageProvider) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +// Sample time range for tests +const defaultTimeRange: TimeRange = { + from: 'now-1h' as unknown as TimeRange['from'], + to: 'now' as unknown as TimeRange['to'], + raw: { + from: 'now-1h', + to: 'now', + }, +}; + +describe('MetricsModalContext', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock console.error to suppress React act() warnings + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Default mock implementations + mockGenerateMetricData.mockImplementation((metric) => ({ + value: metric, + type: 'counter', + description: 'Test metric', + })); + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + test_metric: { type: 'counter', help: 'Test metric' }, + }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('useMetricsModal hook', () => { + it('should throw error when used outside provider', () => { + expect(() => { + renderHook(() => useMetricsModal()); + }).toThrow('useMetricsModal must be used within a MetricsModalContextProvider'); + }); + + it('should provide context value when used within provider', () => { + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + expect(result.current.isLoading).toBe(true); // Initially loading + expect(result.current.filteredMetricsData).toEqual([]); + expect(result.current.pagination).toEqual({ + pageNum: 1, + totalPageNum: 1, + resultsPerPage: DEFAULT_RESULTS_PER_PAGE, + }); + expect(result.current.selectedTypes).toEqual([]); + expect(result.current.searchedText).toBe(''); + }); + }); + + describe('State management', () => { + it('should update pagination', () => { + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + const expectedPagination = { pageNum: 1, resultsPerPage: 50, totalPageNum: 1 }; + + act(() => { + result.current.setPagination({ pageNum: 2, resultsPerPage: 50, totalPageNum: 3 }); + }); + + expect(result.current.pagination).toEqual(expectedPagination); + }); + + it('should update selected types', () => { + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + const newTypes = [{ value: 'counter', label: 'Counter' }]; + + act(() => { + result.current.setSelectedTypes(newTypes); + }); + + expect(result.current.selectedTypes).toEqual(newTypes); + }); + + it('should update searched text', () => { + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSearchedText('test_metric'); + }); + + expect(result.current.searchedText).toBe('test_metric'); + }); + + it('should update loading state', () => { + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setIsLoading(false); + }); + + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('Metadata fetching', () => { + it('should load initial metadata on mount', async () => { + const mockMetadata = { + cpu_usage: { type: 'gauge', help: 'CPU usage percentage' }, + memory_usage: { type: 'gauge', help: 'Memory usage bytes' }, + }; + + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue(mockMetadata); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + // Wait for metadata to load + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockLanguageProvider.queryMetricsMetadata).toHaveBeenCalledWith(1000); + expect(mockGenerateMetricData).toHaveBeenCalledWith('cpu_usage', mockLanguageProvider); + expect(mockGenerateMetricData).toHaveBeenCalledWith('memory_usage', mockLanguageProvider); + expect(result.current.filteredMetricsData).toHaveLength(2); + }); + + it('should handle empty metadata response', async () => { + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({}); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.filteredMetricsData).toEqual([]); + }); + + it('should handle metadata fetch error', async () => { + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.filteredMetricsData).toEqual([]); + }); + }); + + describe('Backend search', () => { + it('should perform backend search with results', async () => { + (mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['test_metric', 'other_metric']); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.debouncedBackendSearch(defaultTimeRange, 'test'); + }); + + expect(mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith( + defaultTimeRange, + '__name__', + '{__name__=~"(?i).*test.*"}' + ); + expect(result.current.filteredMetricsData).toHaveLength(1); + }); + + it('should handle backend search error', async () => { + (mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValue(new Error('Search failed')); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.debouncedBackendSearch(defaultTimeRange, 'test'); + }); + + expect(result.current.filteredMetricsData).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('Filtering logic', () => { + it('should return all metrics when no types are selected', async () => { + mockGenerateMetricData.mockImplementation((metric) => ({ + value: metric, + type: 'counter', + description: 'Test metric', + })); + + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + test_metric: { type: 'counter', help: 'Test metric' }, + }); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.selectedTypes).toEqual([]); + }); + + it('should filter metrics by selected type', async () => { + mockGenerateMetricData.mockImplementation((metric) => ({ + value: metric, + type: metric === 'counter_metric' ? 'counter' : 'gauge', + description: 'Test metric', + })); + + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + counter_metric: { type: 'counter', help: 'Counter metric' }, + gauge_metric: { type: 'gauge', help: 'Gauge metric' }, + }); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setSelectedTypes([{ value: 'counter', label: 'Counter' }]); + }); + + expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.filteredMetricsData[0].value).toBe('counter_metric'); + }); + + it('should handle metrics without type when "no type" is selected', async () => { + mockGenerateMetricData.mockImplementation((metric) => ({ + value: metric, + type: metric === 'no_type_metric' ? undefined : 'counter', + description: 'Test metric', + })); + + (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + counter_metric: { type: 'counter', help: 'Counter metric' }, + no_type_metric: { help: 'Metric without type' }, + }); + + const { result } = renderHook(() => useMetricsModal(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setSelectedTypes([{ value: 'no type', label: 'No Type' }]); + }); + + expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.filteredMetricsData[0].value).toBe('no_type_metric'); + }); + }); + + describe('Component integration', () => { + it('should render provider without errors', () => { + const TestComponent = () => { + return
frontend
; + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('test')).toHaveTextContent('frontend'); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx new file mode 100644 index 00000000000..3361b448547 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx @@ -0,0 +1,201 @@ +import debounce from 'debounce-promise'; +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { SelectableValue, TimeRange } from '@grafana/data'; + +import { METRIC_LABEL, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from '../../../constants'; +import { PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { regexifyLabelValuesQueryString } from '../../parsingUtils'; +import { QueryBuilderLabelFilter } from '../../shared/types'; +import { formatPrometheusLabelFilters } from '../formatter'; + +import { generateMetricData } from './helpers'; +import { MetricData, MetricsData } from './types'; +import { fuzzySearch } from './uFuzzy'; + +export const DEFAULT_RESULTS_PER_PAGE = 25; + +type Pagination = { + pageNum: number; + resultsPerPage: number; + totalPageNum: number; +}; + +type MetricsModalContextValue = { + isLoading: boolean; + setIsLoading: (val: boolean) => void; + filteredMetricsData: MetricData[]; + debouncedBackendSearch: ( + timeRange: TimeRange, + metricText: string, + queryLabels?: QueryBuilderLabelFilter[] + ) => Promise; + pagination: Pagination; + setPagination: (val: Pagination) => void; + selectedTypes: Array>; + setSelectedTypes: (val: Array>) => void; + searchedText: string; + setSearchedText: (val: string) => void; +}; + +const MetricsModalContext = createContext(undefined); + +type MetricsModalContextProviderProps = { + languageProvider: PrometheusLanguageProviderInterface; +}; + +export const MetricsModalContextProvider: FC> = ({ + children, + languageProvider, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [metricsData, setMetricsData] = useState([]); + const [pagination, setPagination] = useState({ + pageNum: 1, + totalPageNum: 1, + resultsPerPage: DEFAULT_RESULTS_PER_PAGE, + }); + const [selectedTypes, setSelectedTypes] = useState>>([]); + const [searchedText, setSearchedText] = useState(''); + + const filteredMetricsData = useMemo(() => { + if (selectedTypes.length === 0) { + return metricsData; + } + + // Filter metrics based on selected types + return metricsData.filter((metric: MetricData) => { + return selectedTypes.some((selectedType) => { + // Handle metrics with defined types + if (metric.type && selectedType.value) { + return metric.type.includes(selectedType.value); + } + + // Handle metrics without type when "no type" is selected + if (!metric.type && selectedType.value === 'no type') { + return true; + } + + return false; + }); + }); + }, [metricsData, selectedTypes]); + + useEffect(() => { + const totalPageNum = + filteredMetricsData.length === 0 ? 1 : Math.ceil(filteredMetricsData.length / pagination.resultsPerPage); + const pageNum = pagination.pageNum > totalPageNum ? 1 : pagination.pageNum; + + setPagination((prevPagination) => ({ + ...prevPagination, + totalPageNum, + pageNum, + })); + }, [filteredMetricsData.length, pagination.resultsPerPage, pagination.pageNum]); + + // Track the latest search ID to handle race conditions + const latestSearchIdRef = useRef(0); + + const fetchMetadata = useCallback(async () => { + try { + setIsLoading(true); + const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); + + if (Object.keys(metadata).length === 0) { + setMetricsData([]); + } else { + const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider)); + setMetricsData(processedData); + } + } catch (error) { + setMetricsData([]); + } finally { + setIsLoading(false); + } + }, [languageProvider]); + + const debouncedBackendSearch = useMemo( + () => + debounce(async (timeRange: TimeRange, metricText: string, queryLabels?: QueryBuilderLabelFilter[]) => { + // Generate unique search ID to handle race conditions + const searchId = ++latestSearchIdRef.current; + + try { + if (metricText === '') { + await fetchMetadata(); + return; + } + + setIsLoading(true); + + const queryString = regexifyLabelValuesQueryString(metricText); + const filterArray = queryLabels ? formatPrometheusLabelFilters(queryLabels) : []; + const match = `{__name__=~"(?i).*${queryString}"${filterArray ? filterArray.join('') : ''}}`; + + const results = await languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match); + + // Check if this is still the most recent search + if (searchId !== latestSearchIdRef.current) { + return; // Ignore outdated results + } + + const [fuzzyOrderedMetrics] = fuzzySearch(results, queryString); + const resultsOptions: MetricsData = fuzzyOrderedMetrics.map((m) => generateMetricData(m, languageProvider)); + + setMetricsData(resultsOptions); + setIsLoading(false); + } catch (error) { + // Only update state if this is still the latest search + if (searchId === latestSearchIdRef.current) { + console.error('Backend search failed:', error); + setMetricsData([]); // Clear results on error + setIsLoading(false); + } + } + }, 300), + [fetchMetadata, languageProvider] + ); + + useEffect(() => { + fetchMetadata(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +}; + +export function useMetricsModal() { + const context = useContext(MetricsModalContext); + if (context === undefined) { + throw new Error('useMetricsModal must be used within a MetricsModalContextProvider'); + } + return context; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx index 1e179d336ee..3f3a1bf5afc 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx @@ -1,65 +1,62 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx -import { css } from '@emotion/css'; -import { ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; import Highlighter from 'react-highlight-words'; -import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui'; +import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; import { docsTip } from '../../../configuration/shared/utils'; import { PromVisualQuery } from '../../types'; -import { tracking } from './state/helpers'; -import { MetricsModalState } from './state/state'; -import { MetricData, MetricsData } from './types'; +import { useMetricsModal } from './MetricsModalContext'; +import { getResultsTableStyles } from './styles'; +import { MetricData } from './types'; type ResultsTableProps = { - metrics: MetricsData; onChange: (query: PromVisualQuery) => void; onClose: () => void; query: PromVisualQuery; - state: MetricsModalState; - disableTextWrap: boolean; }; export function ResultsTable(props: ResultsTableProps) { - const { metrics, onChange, onClose, query, state, disableTextWrap } = props; + const { onChange, onClose, query } = props; + const { + isLoading, + filteredMetricsData, + pagination: { pageNum, resultsPerPage }, + selectedTypes, + searchedText, + } = useMetricsModal(); - const theme = useTheme2(); - const styles = getStyles(theme, disableTextWrap); + const slicedMetrics = useMemo(() => { + const startIndex = (pageNum - 1) * resultsPerPage; + const endIndex = startIndex + resultsPerPage; + return filteredMetricsData.slice(startIndex, endIndex); + }, [filteredMetricsData, pageNum, resultsPerPage]); + + const styles = useStyles2(getResultsTableStyles); function selectMetric(metric: MetricData) { if (metric.value) { onChange({ ...query, metric: metric.value }); - tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value); onClose(); } } function metaRows(metric: MetricData) { - if (state.fullMetaSearch && metric) { - return ( - <> - {displayType(metric.type ?? '')} - - - - - ); - } else { - return ( - <> - {displayType(metric.type ?? '')} - {metric.description ?? ''} - - ); - } + return ( + <> + {displayType(metric.type ?? '')} + + + + + ); } function addHelpIcon(fullType: string, descriptiveType: string, link: string) { @@ -105,7 +102,7 @@ export function ResultsTable(props: ResultsTableProps) { function noMetricsMessages(): ReactElement { let message; - if (!state.fuzzySearchQuery) { + if (!searchedText) { message = t( 'grafana-prometheus.querybuilder.results-table.message-no-metrics-found', 'There are no metrics found in the data source.' @@ -119,7 +116,7 @@ export function ResultsTable(props: ResultsTableProps) { ); } - if (state.fuzzySearchQuery || state.selectedTypes.length > 0) { + if (searchedText || selectedTypes.length > 0) { message = t( 'grafana-prometheus.querybuilder.results-table.message-expand-search', 'There are no metrics found. Try to expand your search and filters.' @@ -133,21 +130,6 @@ export function ResultsTable(props: ResultsTableProps) { ); } - function textHighlight(state: MetricsModalState) { - if (state.useBackend) { - // highlight the input only for the backend search - // this highlight is equivalent to how the metric select highlights - // look into matching on regex input - return [state.fuzzySearchQuery]; - } else if (state.fullMetaSearch) { - // highlight the matches in the ufuzzy metaHaystack - return state.metaHaystackMatches; - } else { - // highlight the ufuzzy name matches - return state.nameHaystackMatches; - } - } - return ( @@ -155,115 +137,35 @@ export function ResultsTable(props: ResultsTableProps) { - {state.hasMetadata && ( - <> - - - - )} - + + <> - {metrics.length > 0 && - metrics.map((metric: MetricData, idx: number) => { + {slicedMetrics.length > 0 && + slicedMetrics.map((metric: MetricData, idx: number) => { return ( - + selectMetric(metric)}> - {state.hasMetadata && metaRows(metric)} - + {metaRows(metric)} ); })} - {metrics.length === 0 && !state.isLoading && noMetricsMessages()} + {slicedMetrics.length === 0 && !isLoading && noMetricsMessages()}
Name - Type - - Description - + Type + + Description +
- -
); } - -const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { - return { - table: css({ - tableLayout: disableTextWrap ? undefined : 'fixed', - borderRadius: theme.shape.radius.default, - width: '100%', - whiteSpace: disableTextWrap ? 'nowrap' : 'normal', - td: { - padding: theme.spacing(1), - }, - 'td,th': { - minWidth: theme.spacing(3), - borderBottom: `1px solid ${theme.colors.border.weak}`, - }, - }), - row: css({ - label: 'row', - borderBottom: `1px solid ${theme.colors.border.weak}`, - '&:last-child': { - borderBottom: 0, - }, - }), - tableHeaderPadding: css({ - padding: '8px', - }), - matchHighLight: css({ - background: 'inherit', - color: theme.components.textHighlight.text, - backgroundColor: theme.components.textHighlight.background, - }), - nameWidth: css({ - width: disableTextWrap ? undefined : '37.5%', - }), - nameOverflow: css({ - overflowWrap: disableTextWrap ? undefined : 'anywhere', - }), - typeWidth: css({ - width: disableTextWrap ? undefined : '15%', - }), - descriptionWidth: css({ - width: disableTextWrap ? undefined : '35%', - }), - selectButtonWidth: css({ - width: disableTextWrap ? undefined : '12.5%', - }), - stickyHeader: css({ - position: 'sticky', - top: 0, - backgroundColor: theme.colors.background.primary, - }), - noResults: css({ - textAlign: 'center', - color: theme.colors.text.secondary, - }), - tooltipSpace: css({ - marginLeft: '4px', - }), - centerButton: css({ - display: 'block', - margin: 'auto', - border: 'none', - }), - }; -}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/helpers.test.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/helpers.test.ts new file mode 100644 index 00000000000..d3998e46de0 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/helpers.test.ts @@ -0,0 +1,313 @@ +import { PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { PromMetricsMetadata } from '../../../types'; + +import { calculatePageList, generateMetricData, getPlaceholders, getPromTypes } from './helpers'; +import { MetricsData } from './types'; + +// Mock the language provider +const createMockLanguageProvider = (metadata: PromMetricsMetadata = {}): PrometheusLanguageProviderInterface => + ({ + retrieveMetricsMetadata: jest.fn().mockReturnValue(metadata), + }) as unknown as PrometheusLanguageProviderInterface; + +describe('helpers.ts', () => { + describe('generateMetricData', () => { + it('should generate basic metric data', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: 'counter', + help: 'Test counter metric', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + + expect(result).toEqual({ + value: 'test_metric', + type: 'counter', + description: 'Test counter metric', + }); + }); + + it('should handle metric with no metadata', () => { + const mockProvider = createMockLanguageProvider({}); + + const result = generateMetricData('unknown_metric', mockProvider); + + expect(result).toEqual({ + value: 'unknown_metric', + type: undefined, + description: undefined, + }); + }); + + it('should enhance type based on description for histogram', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: 'gauge', + help: 'This is a histogram metric for testing', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + + expect(result.type).toBe('gauge (histogram)'); + }); + + it('should enhance type based on description for summary', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: 'counter', + help: 'This is a summary metric for testing', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + + expect(result.type).toBe('counter (summary)'); + }); + + it('should not enhance type if already matches description', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: 'histogram', + help: 'This is a histogram metric for testing', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + + expect(result.type).toBe('native histogram'); // Should be native histogram, not enhanced + }); + + it('should detect native histogram vs classic histogram', () => { + const mockProvider = createMockLanguageProvider({ + native_histogram: { + type: 'histogram', + help: 'Native histogram', + }, + classic_bucket: { + type: 'histogram', + help: 'Classic histogram', + }, + classic_bucket_with_labels: { + type: 'histogram', + help: 'Classic histogram with labels', + }, + }); + + // Native histogram (no _bucket suffix) + const nativeResult = generateMetricData('native_histogram', mockProvider); + expect(nativeResult.type).toBe('native histogram'); + + // Classic histogram (with _bucket suffix) + const classicResult = generateMetricData('classic_bucket', mockProvider); + expect(classicResult.type).toBe('histogram'); + + // Classic histogram with labels + const classicWithLabelsResult = generateMetricData('classic_bucket_with_labels', mockProvider); + expect(classicWithLabelsResult.type).toBe('native histogram'); // No _bucket pattern match + }); + + it('should handle old histogram pattern matching', () => { + const mockProvider = createMockLanguageProvider({ + test_bucket: { type: 'histogram', help: 'Test bucket metric' }, + 'test_bucket{le="0.1"}': { type: 'histogram', help: 'Test bucket with labels' }, + test_histogram: { type: 'histogram', help: 'Test histogram metric' }, + }); + + // Should be classic histogram (matches pattern) + expect(generateMetricData('test_bucket', mockProvider).type).toBe('histogram'); + expect(generateMetricData('test_bucket{le="0.1"}', mockProvider).type).toBe('histogram'); + + // Should be native histogram (doesn't match pattern) + expect(generateMetricData('test_histogram', mockProvider).type).toBe('native histogram'); + }); + + it('should handle case-insensitive description matching', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: 'gauge', + help: 'This is a HISTOGRAM metric for testing', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + expect(result.type).toBe('gauge (histogram)'); + }); + + it('should handle empty type with description enhancement', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: '', + help: 'This is a histogram metric for testing', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + expect(result.type).toBe('native histogram'); + }); + + it('should handle empty description', () => { + const mockProvider = createMockLanguageProvider({ + test_metric: { + type: 'gauge', + help: '', + }, + }); + + const result = generateMetricData('test_metric', mockProvider); + expect(result.type).toBe('gauge'); + }); + }); + + describe('calculatePageList', () => { + const createMetricsData = (length: number): MetricsData => + Array.from({ length }, (_, i) => ({ value: `metric_${i}` })); + + it('should return empty array for empty metrics data', () => { + expect(calculatePageList([], 10)).toEqual([]); + }); + + it('should return [1] for zero or negative results per page', () => { + const metricsData = createMetricsData(5); + expect(calculatePageList(metricsData, 0)).toEqual([1]); + expect(calculatePageList(metricsData, -5)).toEqual([1]); + }); + + it('should calculate correct page list for exact division', () => { + const metricsData = createMetricsData(20); + const result = calculatePageList(metricsData, 10); + expect(result).toEqual([1, 2]); + }); + + it('should calculate correct page list for non-exact division', () => { + const metricsData = createMetricsData(23); + const result = calculatePageList(metricsData, 10); + expect(result).toEqual([1, 2, 3]); + }); + + it('should handle single page scenario', () => { + const metricsData = createMetricsData(5); + const result = calculatePageList(metricsData, 10); + expect(result).toEqual([1]); + }); + + it('should handle very large datasets', () => { + const metricsData = createMetricsData(1000); + const result = calculatePageList(metricsData, 25); + expect(result).toHaveLength(40); + expect(result[0]).toBe(1); + expect(result[result.length - 1]).toBe(40); + }); + + it('should handle single item per page', () => { + const metricsData = createMetricsData(3); + const result = calculatePageList(metricsData, 1); + expect(result).toEqual([1, 2, 3]); + }); + + it('should handle fractional results per page', () => { + const metricsData = createMetricsData(10); + expect(calculatePageList(metricsData, 3.5)).toEqual([1, 2, 3]); + }); + }); + + describe('getPromTypes', () => { + it('should return array of Prometheus types', () => { + const types = getPromTypes(); + + expect(Array.isArray(types)).toBe(true); + expect(types.length).toBe(7); + + const expectedValues = ['counter', 'gauge', 'histogram', 'native histogram', 'summary', 'unknown', 'no type']; + const actualValues = types.map((type) => type.value); + + expect(actualValues).toEqual(expectedValues); + }); + + it('should have correct structure for each type', () => { + const types = getPromTypes(); + + types.forEach((type) => { + expect(type).toHaveProperty('value'); + expect(type).toHaveProperty('label'); + expect(type).toHaveProperty('description'); + + expect(typeof type.value).toBe('string'); + expect(typeof type.label).toBe('string'); + expect(typeof type.description).toBe('string'); + + expect(type.value.length).toBeGreaterThan(0); + expect(type.label.length).toBeGreaterThan(0); + expect(type.description.length).toBeGreaterThan(0); + }); + }); + + it('should return consistent results on multiple calls', () => { + const types1 = getPromTypes(); + const types2 = getPromTypes(); + + expect(types1).toEqual(types2); + }); + + it('should have unique values', () => { + const types = getPromTypes(); + const values = types.map((type) => type.value); + const uniqueValues = [...new Set(values)]; + + expect(values.length).toBe(uniqueValues.length); + }); + + it('should have descriptive labels', () => { + const types = getPromTypes(); + + types.forEach((type) => { + expect(type.label).not.toBe(type.value); + expect(type.label.length).toBeGreaterThanOrEqual(type.value.length); + }); + }); + }); + + describe('getPlaceholders', () => { + it('should return object with all required placeholders', () => { + const placeholders = getPlaceholders(); + + const expectedKeys = ['browse', 'filterType']; + + expect(Object.keys(placeholders)).toEqual(expectedKeys); + }); + + it('should have string values for all placeholders', () => { + const placeholders = getPlaceholders(); + + Object.values(placeholders).forEach((placeholder) => { + expect(typeof placeholder).toBe('string'); + expect(placeholder.length).toBeGreaterThan(0); + }); + }); + + it('should return consistent results on multiple calls', () => { + const placeholders1 = getPlaceholders(); + const placeholders2 = getPlaceholders(); + + expect(placeholders1).toEqual(placeholders2); + }); + + it('should contain expected placeholder content', () => { + const placeholders = getPlaceholders(); + + expect(placeholders.browse).toMatch(/search/i); + expect(placeholders.filterType).toMatch(/type/i); + }); + + it('should have descriptive placeholder text', () => { + const placeholders = getPlaceholders(); + + Object.values(placeholders).forEach((placeholder) => { + expect(placeholder.length).toBeGreaterThan(5); + expect(placeholder).toMatch(/[a-zA-Z]/); + }); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/helpers.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/helpers.ts new file mode 100644 index 00000000000..9377a22e5e0 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/helpers.ts @@ -0,0 +1,123 @@ +// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts +import { t } from '@grafana/i18n'; + +import { PrometheusLanguageProviderInterface } from '../../../language_provider'; + +import { MetricData, MetricsData, PromFilterOption } from './types'; + +// Constants +const HISTOGRAM_TYPES = ['histogram', 'summary'] as const; +const OLD_HISTOGRAM_PATTERN = /^\w+_bucket$|^\w+_bucket{.*}$/; +const HISTOGRAM_TYPE = 'histogram'; +const NATIVE_HISTOGRAM_TYPE = 'native histogram'; + +/** + * Builds the metric data object with type and description + * @param metric - The metric name + * @param languageProvider - The Prometheus language provider interface + * @returns MetricData object with value, type, and description + */ +export const generateMetricData = ( + metric: string, + languageProvider: PrometheusLanguageProviderInterface +): MetricData => { + const metadata = languageProvider.retrieveMetricsMetadata(); + + let type = metadata[metric]?.type; + const description = metadata[metric]?.help; + + HISTOGRAM_TYPES.forEach((t) => { + if (description?.toLowerCase().includes(t) && type !== t) { + type = type ? `${type} (${t})` : t; + } + }); + + const oldHistogramMatch = metric.match(OLD_HISTOGRAM_PATTERN); + + if (type === HISTOGRAM_TYPE && !oldHistogramMatch) { + type = NATIVE_HISTOGRAM_TYPE; + } + + return { + value: metric, + type: type, + description: description, + }; +}; + +export function calculatePageList(metricsData: MetricsData, resultsPerPage: number): number[] { + if (!Array.isArray(metricsData) || metricsData.length === 0) { + return []; + } + + if (resultsPerPage <= 0) { + return [1]; + } + + const totalPages = Math.ceil(metricsData.length / resultsPerPage); + return Array.from({ length: totalPages }, (_, i) => i + 1); +} + +export const getPromTypes: () => PromFilterOption[] = () => [ + { + value: 'counter', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-counter', 'Counter'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-counter', + 'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.' + ), + }, + { + value: 'gauge', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-gauge', 'Gauge'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-gauge', + 'A metric that represents a single numerical value that can arbitrarily go up and down.' + ), + }, + { + value: 'histogram', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-histogram', 'Histogram'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-histogram', + 'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.' + ), + }, + { + value: 'native histogram', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-native-histogram', 'Native histogram'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-native-histogram', + 'Native histograms are different from classic Prometheus histograms in a number of ways: Native histogram bucket boundaries are calculated by a formula that depends on the scale (resolution) of the native histogram, and are not user defined.' + ), + }, + { + value: 'summary', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-summary', 'Summary'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-summary', + 'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.' + ), + }, + { + value: 'unknown', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-unknown', 'Unknown'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-unknown', + 'These metrics have been given the type unknown in the metadata.' + ), + }, + { + value: 'no type', + label: t('grafana-prometheus.querybuilder.get-prom-types.label-no-type', 'No type'), + description: t( + 'grafana-prometheus.querybuilder.get-prom-types.description-no-type', + 'These metrics have no defined type in the metadata.' + ), + }, +]; + +export const getPlaceholders = () => ({ + browse: t('grafana-prometheus.querybuilder.get-placeholders.browse', 'Search metrics by name'), + filterType: t('grafana-prometheus.querybuilder.get-placeholders.type', 'Filter by type'), +}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts deleted file mode 100644 index 76ee015c1e9..00000000000 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TimeRange } from '@grafana/data'; - -import { PrometheusDatasource } from '../../../../datasource'; -import { PromVisualQuery } from '../../../types'; - -interface MetricsModalState { - useBackend: boolean; - disableTextWrap: boolean; - includeNullMetadata: boolean; - fullMetaSearch: boolean; - hasMetadata: boolean; -} - -export interface MetricsModalProps { - datasource: PrometheusDatasource; - isOpen: boolean; - query: PromVisualQuery; - onClose: () => void; - onChange: (query: PromVisualQuery) => void; - initialMetrics: string[] | (() => Promise); - timeRange: TimeRange; -} - -export interface AdditionalSettingsProps { - state: MetricsModalState; - onChangeFullMetaSearch: () => void; - onChangeIncludeNullMetadata: () => void; - onChangeDisableTextWrap: () => void; - onChangeUseBackend: () => void; -} diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts deleted file mode 100644 index 79d41287197..00000000000 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts +++ /dev/null @@ -1,278 +0,0 @@ -// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts -import { AnyAction } from '@reduxjs/toolkit'; - -import { t } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; - -import { PrometheusDatasource } from '../../../../datasource'; -import { PromMetricsMetadata } from '../../../../types'; -import { PromVisualQuery } from '../../../types'; -import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types'; - -import { MetricsModalMetadata, MetricsModalState, setFilteredMetricCount } from './state'; - -export async function setMetrics( - datasource: PrometheusDatasource, - query: PromVisualQuery, - initialMetrics?: string[] -): Promise { - // metadata is set in the metric select now - // use this to disable metadata search and display - let hasMetadata = true; - const metadata = datasource.languageProvider.retrieveMetricsMetadata(); - if (metadata && Object.keys(metadata).length === 0) { - hasMetadata = false; - } - - let nameHaystackDictionaryData: HaystackDictionary = {}; - let metaHaystackDictionaryData: HaystackDictionary = {}; - - // pass in metrics from getMetrics in the query builder, reduced in the metric select - let metricsData: MetricsData | undefined; - - metricsData = initialMetrics?.map((m: string) => { - const metricData = buildMetricData(m, datasource); - - const metaDataString = `${m}¦${metricData.description}`; - - nameHaystackDictionaryData[m] = metricData; - metaHaystackDictionaryData[metaDataString] = metricData; - - return metricData; - }); - - return { - isLoading: false, - hasMetadata: hasMetadata, - metrics: metricsData ?? [], - metaHaystackDictionary: metaHaystackDictionaryData, - nameHaystackDictionary: nameHaystackDictionaryData, - totalMetricCount: metricsData?.length ?? 0, - filteredMetricCount: metricsData?.length ?? 0, - }; -} - -/** - * Builds the metric data object with type and description - * - * @param metric The metric name - * @param datasource The Prometheus datasource for mapping metradata to the metric name - * @returns A MetricData object. - */ -function buildMetricData(metric: string, datasource: PrometheusDatasource): MetricData { - let type = getMetadataType(metric, datasource.languageProvider.retrieveMetricsMetadata()); - - const description = getMetadataHelp(metric, datasource.languageProvider.retrieveMetricsMetadata()); - - ['histogram', 'summary'].forEach((t) => { - if (description?.toLowerCase().includes(t) && type !== t) { - type += ` (${t})`; - } - }); - - const oldHistogramMatch = metric.match(/^\w+_bucket$|^\w+_bucket{.*}$/); - - if (type === 'histogram' && !oldHistogramMatch) { - type = 'native histogram'; - } - - const metricData: MetricData = { - value: metric, - type: type, - description: description, - }; - - return metricData; -} - -function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined { - return metadata[metric]?.help; -} - -function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined { - return metadata[metric]?.type; -} - -/** - * The filtered and paginated metrics displayed in the modal - * */ -export function displayedMetrics(state: MetricsModalState, dispatch: React.Dispatch) { - const filteredSorted: MetricsData = filterMetrics(state); - - if (!state.isLoading && state.filteredMetricCount !== filteredSorted.length) { - dispatch(setFilteredMetricCount(filteredSorted.length)); - } - - return sliceMetrics(filteredSorted, state.pageNum, state.resultsPerPage); -} - -/** - * Filter the metrics with all the options, fuzzy, type, null metadata - */ -function filterMetrics(state: MetricsModalState): MetricsData { - let filteredMetrics: MetricsData = state.metrics; - - if (state.fuzzySearchQuery && !state.useBackend) { - if (state.fullMetaSearch) { - filteredMetrics = state.metaHaystackOrder.map((needle: string) => state.metaHaystackDictionary[needle]); - } else { - filteredMetrics = state.nameHaystackOrder.map((needle: string) => state.nameHaystackDictionary[needle]); - } - } - - if (state.selectedTypes.length > 0) { - filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => { - // Matches type - const matchesSelectedType = state.selectedTypes.some((t) => { - if (m.type && t.value) { - return m.type.includes(t.value); - } - - if (!m.type && t.value === 'no type') { - return true; - } - - return false; - }); - - // when a user filters for type, only return metrics with defined types - return matchesSelectedType; - }); - } - - if (!state.includeNullMetadata) { - filteredMetrics = filteredMetrics.filter((m: MetricData) => { - return m.type !== undefined && m.description !== undefined; - }); - } - - return filteredMetrics; -} - -export function calculatePageList(state: MetricsModalState) { - if (!state.metrics.length) { - return []; - } - - const calcResultsPerPage: number = state.resultsPerPage === 0 ? 1 : state.resultsPerPage; - - const pages = Math.floor(filterMetrics(state).length / calcResultsPerPage) + 1; - - return [...Array(pages).keys()].map((i) => i + 1); -} - -function sliceMetrics(metrics: MetricsData, pageNum: number, resultsPerPage: number) { - const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage; - const start: number = pageNum === 1 ? 0 : (pageNum - 1) * calcResultsPerPage; - const end: number = start + calcResultsPerPage; - return metrics.slice(start, end); -} - -export const calculateResultsPerPage = (results: number, defaultResults: number, max: number) => { - if (results < 1) { - return 1; - } - - if (results > max) { - return max; - } - - return results ?? defaultResults; -}; - -export function tracking(event: string, state?: MetricsModalState | null, metric?: string, query?: PromVisualQuery) { - switch (event) { - case 'grafana_prom_metric_encycopedia_tracking': - reportInteraction(event, { - metric: metric, - hasMetadata: state?.hasMetadata, - totalMetricCount: state?.totalMetricCount, - fuzzySearchQuery: state?.fuzzySearchQuery, - fullMetaSearch: state?.fullMetaSearch, - selectedTypes: state?.selectedTypes, - useRegexSearch: state?.useBackend, - includeResultsWithoutMetadata: state?.includeNullMetadata, - }); - case 'grafana_prom_metric_encycopedia_disable_text_wrap_interaction': - reportInteraction(event, { - disableTextWrap: state?.disableTextWrap, - }); - case 'grafana_prometheus_metric_encyclopedia_open': - reportInteraction(event, { - query: query, - }); - } -} - -export const getPromTypes: () => PromFilterOption[] = () => [ - { - value: 'counter', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-counter', 'Counter'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-counter', - 'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.' - ), - }, - { - value: 'gauge', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-gauge', 'Gauge'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-gauge', - 'A metric that represents a single numerical value that can arbitrarily go up and down.' - ), - }, - { - value: 'histogram', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-histogram', 'Histogram'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-histogram', - 'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.' - ), - }, - { - value: 'native histogram', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-native-histogram', 'Native histogram'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-native-histogram', - 'Native histograms are different from classic Prometheus histograms in a number of ways: Native histogram bucket boundaries are calculated by a formula that depends on the scale (resolution) of the native histogram, and are not user defined.' - ), - }, - { - value: 'summary', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-summary', 'Summary'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-summary', - 'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.' - ), - }, - { - value: 'unknown', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-unknown', 'Unknown'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-unknown', - 'These metrics have been given the type unknown in the metadata.' - ), - }, - { - value: 'no type', - label: t('grafana-prometheus.querybuilder.get-prom-types.label-no-type', 'No type'), - description: t( - 'grafana-prometheus.querybuilder.get-prom-types.description-no-type', - 'These metrics have no defined type in the metadata.' - ), - }, -]; - -export const getPlaceholders = () => ({ - browse: t('grafana-prometheus.querybuilder.get-placeholders.browse', 'Search metrics by name'), - metadataSearchSwitch: t( - 'grafana-prometheus.querybuilder.get-placeholders.metadata-search-switch', - 'Include description in search' - ), - type: t('grafana-prometheus.querybuilder.get-placeholders.type', 'Filter by type'), - includeNullMetadata: t( - 'grafana-prometheus.querybuilder.get-placeholders.include-null-metadata', - 'Include results with no metadata' - ), - setUseBackend: t('grafana-prometheus.querybuilder.get-placeholders.set-use-backend', 'Enable regex search'), -}); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts deleted file mode 100644 index 05031eee4db..00000000000 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts +++ /dev/null @@ -1,212 +0,0 @@ -// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts -import { PayloadAction, createSlice } from '@reduxjs/toolkit'; - -import { SelectableValue } from '@grafana/data'; - -import { PromVisualQuery } from '../../../types'; -import { HaystackDictionary, MetricsData } from '../types'; - -export const DEFAULT_RESULTS_PER_PAGE = 100; -export const MAXIMUM_RESULTS_PER_PAGE = 1000; - -/** - * Initial state for the metrics explorer - * @returns - */ -export function initialState(query?: PromVisualQuery): MetricsModalState { - return { - isLoading: true, - metrics: [], - hasMetadata: true, - metaHaystackDictionary: {}, - metaHaystackMatches: [], - metaHaystackOrder: [], - nameHaystackDictionary: {}, - nameHaystackOrder: [], - nameHaystackMatches: [], - totalMetricCount: 0, - filteredMetricCount: null, - resultsPerPage: DEFAULT_RESULTS_PER_PAGE, - pageNum: 1, - fuzzySearchQuery: '', - fullMetaSearch: query?.fullMetaSearch ?? false, - includeNullMetadata: query?.includeNullMetadata ?? true, - selectedTypes: [], - useBackend: query?.useBackend ?? false, - disableTextWrap: query?.disableTextWrap ?? false, - showAdditionalSettings: false, - }; -} - -/** - * The metrics explorer state object - */ -export interface MetricsModalState { - /** Used for the loading spinner */ - isLoading: boolean; - /** - * Initial collection of metrics. - * The frontend filters do not impact this, but - * it is reduced by the backend search. - */ - metrics: MetricsData; - /** Field for disabling type select and switches that rely on metadata */ - hasMetadata: boolean; - /** Used to display metrics and help with fuzzy order */ - nameHaystackDictionary: HaystackDictionary; - /** Used to sort name fuzzy search by relevance */ - nameHaystackOrder: string[]; - /** Used to highlight text in fuzzy matches */ - nameHaystackMatches: string[]; - /** Used to display metrics and help with fuzzy order for search across all metadata */ - metaHaystackDictionary: HaystackDictionary; - /** Used to sort meta fuzzy search by relevance */ - metaHaystackOrder: string[]; - /** Used to highlight text in fuzzy matches */ - metaHaystackMatches: string[]; - /** Total results computed on initialization */ - totalMetricCount: number; - /** Set after filtering metrics */ - filteredMetricCount: number | null; - /** Pagination field for showing results in table */ - resultsPerPage: number; - /** Pagination field */ - pageNum: number; - /** The text query used to match metrics */ - fuzzySearchQuery: string; - /** Enables the fuzzy meatadata search */ - fullMetaSearch: boolean; - /** Includes results that are missing type and description */ - includeNullMetadata: boolean; - /** Filter by prometheus type */ - selectedTypes: Array>; - /** Filter by the series match endpoint instead of the fuzzy search */ - useBackend: boolean; - /** Disable text wrap for descriptions in the results table */ - disableTextWrap: boolean; - /** Display toggle switches for settings */ - showAdditionalSettings: boolean; -} - -/** - * Type for the useEffect get metadata function - */ -export type MetricsModalMetadata = { - isLoading: boolean; - metrics: MetricsData; - hasMetadata: boolean; - metaHaystackDictionary: HaystackDictionary; - nameHaystackDictionary: HaystackDictionary; - totalMetricCount: number; - filteredMetricCount: number | null; -}; - -// for updating the settings in the PromQuery model -export function getSettings(visQuery: PromVisualQuery): MetricsModalSettings { - return { - useBackend: visQuery?.useBackend ?? false, - disableTextWrap: visQuery?.disableTextWrap ?? false, - fullMetaSearch: visQuery?.fullMetaSearch ?? false, - includeNullMetadata: visQuery.includeNullMetadata ?? false, - }; -} - -export type MetricsModalSettings = { - useBackend?: boolean; - disableTextWrap?: boolean; - fullMetaSearch?: boolean; - includeNullMetadata?: boolean; -}; - -export const stateSlice = createSlice({ - name: 'metrics-modal-state', - initialState: initialState(), - reducers: { - filterMetricsBackend: ( - state, - action: PayloadAction<{ - metrics: MetricsData; - filteredMetricCount: number; - isLoading: boolean; - }> - ) => { - state.metrics = action.payload.metrics; - state.filteredMetricCount = action.payload.filteredMetricCount; - state.isLoading = action.payload.isLoading; - }, - buildMetrics: (state, action: PayloadAction) => { - state.isLoading = action.payload.isLoading; - state.metrics = action.payload.metrics; - state.hasMetadata = action.payload.hasMetadata; - state.metaHaystackDictionary = action.payload.metaHaystackDictionary; - state.nameHaystackDictionary = action.payload.nameHaystackDictionary; - state.totalMetricCount = action.payload.totalMetricCount; - state.filteredMetricCount = action.payload.filteredMetricCount; - }, - setIsLoading: (state, action: PayloadAction) => { - state.isLoading = action.payload; - }, - setFilteredMetricCount: (state, action: PayloadAction) => { - state.filteredMetricCount = action.payload; - }, - setResultsPerPage: (state, action: PayloadAction) => { - state.resultsPerPage = action.payload; - }, - setPageNum: (state, action: PayloadAction) => { - state.pageNum = action.payload; - }, - setFuzzySearchQuery: (state, action: PayloadAction) => { - state.fuzzySearchQuery = action.payload; - state.pageNum = 1; - }, - setNameHaystack: (state, action: PayloadAction) => { - state.nameHaystackOrder = action.payload[0]; - state.nameHaystackMatches = action.payload[1]; - }, - setMetaHaystack: (state, action: PayloadAction) => { - state.metaHaystackOrder = action.payload[0]; - state.metaHaystackMatches = action.payload[1]; - }, - setFullMetaSearch: (state, action: PayloadAction) => { - state.fullMetaSearch = action.payload; - state.pageNum = 1; - }, - setIncludeNullMetadata: (state, action: PayloadAction) => { - state.includeNullMetadata = action.payload; - state.pageNum = 1; - }, - setSelectedTypes: (state, action: PayloadAction>>) => { - state.selectedTypes = action.payload; - state.pageNum = 1; - }, - setUseBackend: (state, action: PayloadAction) => { - state.useBackend = action.payload; - state.fullMetaSearch = false; - state.pageNum = 1; - }, - setDisableTextWrap: (state) => { - state.disableTextWrap = !state.disableTextWrap; - }, - showAdditionalSettings: (state) => { - state.showAdditionalSettings = !state.showAdditionalSettings; - }, - }, -}); - -export const { - setIsLoading, - buildMetrics, - filterMetricsBackend, - setResultsPerPage, - setPageNum, - setFuzzySearchQuery, - setNameHaystack, - setMetaHaystack, - setFullMetaSearch, - setIncludeNullMetadata, - setSelectedTypes, - setUseBackend, - setDisableTextWrap, - showAdditionalSettings, - setFilteredMetricCount, -} = stateSlice.actions; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts index bcc6fa425c7..64e52600055 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { +export const getMetricsModalStyles = (theme: GrafanaTheme2) => { return { modal: css({ width: '85vw', @@ -34,20 +34,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { minWidth: '100%', }, }), - selectWrapper: css({ - marginBottom: theme.spacing(1), - }), - resultsAmount: css({ - color: theme.colors.text.secondary, - fontSize: '0.85rem', - padding: '0 0 4px 0', - }), resultsData: css({ margin: `4px 0 ${theme.spacing(2)} 0`, }), - resultsDataCount: css({ - margin: 0, - }), resultsDataFiltered: css({ color: theme.colors.text.secondary, textAlign: 'center', @@ -67,9 +56,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', - justifyContent: 'space-between', alignItems: 'center', position: 'sticky', + justifyContent: 'center', }), currentlySelected: css({ color: 'grey', @@ -82,21 +71,69 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => { visible: css({ visibility: 'visible', }), - settingsBtn: css({ - float: 'right', - }), noBorder: css({ border: 'none', }), - resultsPerPageLabel: css({ - color: theme.colors.text.secondary, - opacity: '75%', - paddingTop: '5px', - fontSize: '0.85rem', - marginRight: '8px', + }; +}; + +export const getResultsTableStyles = (theme: GrafanaTheme2) => { + return { + table: css({ + tableLayout: 'fixed', + borderRadius: theme.shape.radius.default, + width: '100%', + whiteSpace: 'normal', + td: { + padding: theme.spacing(1), + }, + 'td,th': { + minWidth: theme.spacing(3), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }, }), - resultsPerPageWrapper: css({ - display: 'flex', + row: css({ + label: 'row', + borderBottom: `1px solid ${theme.colors.border.weak}`, + cursor: 'pointer', + '&:last-child': { + borderBottom: 0, + }, + '&:hover': { + backgroundColor: theme.colors.background.secondary, + }, + }), + tableHeaderPadding: css({ + padding: '8px', + }), + matchHighLight: css({ + background: 'inherit', + color: theme.components.textHighlight.text, + backgroundColor: theme.components.textHighlight.background, + }), + nameWidth: css({ + width: '37.5%', + }), + nameOverflow: css({ + overflowWrap: 'anywhere', + }), + typeWidth: css({ + width: '15%', + }), + descriptionWidth: css({ + width: '35%', + }), + stickyHeader: css({ + position: 'sticky', + top: 0, + backgroundColor: theme.colors.background.primary, + }), + noResults: css({ + textAlign: 'center', + color: theme.colors.text.secondary, + }), + tooltipSpace: css({ + marginLeft: '4px', }), }; }; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/testIds.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/testIds.ts similarity index 65% rename from packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/testIds.ts rename to packages/grafana-prometheus/src/querybuilder/components/metrics-modal/testIds.ts index 275394dd348..f1e5c7709e8 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/testIds.ts +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/testIds.ts @@ -1,12 +1,9 @@ export const metricsModaltestIds = { metricModal: 'metric-modal', searchMetric: 'search-metric', - searchWithMetadata: 'search-with-metadata', selectType: 'select-type', metricCard: 'metric-card', useMetric: 'use-metric', searchPage: 'search-page', resultsPerPage: 'results-per-page', - setUseBackend: 'set-use-backend', - showAdditionalSettings: 'show-additional-settings', }; diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts index 297e216b560..3de9b6f1828 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/uFuzzy.ts @@ -1,6 +1,5 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts import uFuzzy from '@leeoniya/ufuzzy'; -import { debounce as debounceLodash } from 'lodash'; const uf = new uFuzzy({ intraMode: 1, @@ -10,37 +9,34 @@ const uf = new uFuzzy({ intraDel: 1, }); -function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) { +export const fuzzySearch = (haystack: string[], query: string) => { const [idxs, info, order] = uf.search(haystack, query, 0, 1e5); let haystackOrder: string[] = []; let matchesSet: Set = new Set(); - if (idxs && order) { - /** - * get the fuzzy matches for hilighting - * @param part - * @param matched - */ - const mark = (part: string, matched: boolean) => { - if (matched) { - matchesSet.add(part); - } - }; - - // Iterate to create the order of needles(queries) and the matches - for (let i = 0; i < order.length; i++) { - let infoIdx = order[i]; - - /** Evaluate the match, get the matches for highlighting */ - uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark); - /** Get the order */ - haystackOrder.push(haystack[info.idx[infoIdx]]); - } - - dispatcher([haystackOrder, [...matchesSet]]); - } else if (!query) { - dispatcher([[], []]); + if (!(idxs && order)) { + return [[], []]; } -} + /** + * get the fuzzy matches for highlighting + * @param part + * @param matched + */ + const mark = (part: string, matched: boolean) => { + if (matched) { + matchesSet.add(part); + } + }; -export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300); + // Iterate to create the order of needles(queries) and the matches + for (let i = 0; i < order.length; i++) { + let infoIdx = order[i]; + + /** Evaluate the match, get the matches for highlighting */ + uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark); + /** Get the order */ + haystackOrder.push(haystack[info.idx[infoIdx]]); + } + + return [haystackOrder, [...matchesSet]]; +}; diff --git a/packages/grafana-prometheus/src/querybuilder/components/shared/BaseQueryBuilder.tsx b/packages/grafana-prometheus/src/querybuilder/components/shared/BaseQueryBuilder.tsx deleted file mode 100644 index 03787aad88a..00000000000 --- a/packages/grafana-prometheus/src/querybuilder/components/shared/BaseQueryBuilder.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { memo } from 'react'; - -import { NestedQueryList } from '../NestedQueryList'; - -import { QueryBuilderContent } from './QueryBuilderContent'; -import { BaseQueryBuilderProps } from './types'; - -export const BaseQueryBuilder = memo((props) => { - const { query, datasource, onChange, onRunQuery, showExplain } = props; - - return ( - <> - - {query.binaryQueries && query.binaryQueries.length > 0 && ( - - )} - - ); -}); - -BaseQueryBuilder.displayName = 'BaseQueryBuilder'; diff --git a/packages/grafana-prometheus/src/querybuilder/components/shared/types.ts b/packages/grafana-prometheus/src/querybuilder/components/shared/types.ts deleted file mode 100644 index 626e6606259..00000000000 --- a/packages/grafana-prometheus/src/querybuilder/components/shared/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { PanelData } from '@grafana/data'; - -import { PrometheusDatasource } from '../../../datasource'; -import { PromVisualQuery } from '../../types'; - -export interface BaseQueryBuilderProps { - query: PromVisualQuery; - datasource: PrometheusDatasource; - onChange: (update: PromVisualQuery) => void; - onRunQuery: () => void; - data?: PanelData; - showExplain: boolean; -} diff --git a/packages/grafana-prometheus/src/querybuilder/types.ts b/packages/grafana-prometheus/src/querybuilder/types.ts index 991cfec8969..c2bc18bc2e5 100644 --- a/packages/grafana-prometheus/src/querybuilder/types.ts +++ b/packages/grafana-prometheus/src/querybuilder/types.ts @@ -14,11 +14,6 @@ export interface PromVisualQuery { labels: QueryBuilderLabelFilter[]; operations: QueryBuilderOperation[]; binaryQueries?: PromVisualQueryBinary[]; - // metrics explorer additional settings - useBackend?: boolean; - disableTextWrap?: boolean; - includeNullMetadata?: boolean; - fullMetaSearch?: boolean; } export interface PromQueryModellerInterface { diff --git a/packages/grafana-prometheus/src/types.ts b/packages/grafana-prometheus/src/types.ts index acd5b803ee2..98730018bf3 100644 --- a/packages/grafana-prometheus/src/types.ts +++ b/packages/grafana-prometheus/src/types.ts @@ -15,11 +15,6 @@ export interface PromQuery extends GenPromQuery, DataQuery { showingTable?: boolean; hinting?: boolean; interval?: string; - // store the metrics explorer additional settings - useBackend?: boolean; - disableTextWrap?: boolean; - fullMetaSearch?: boolean; - includeNullMetadata?: boolean; fromExploreMetrics?: boolean; } diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index f32cc4639da..dfae8069cfe 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -87,7 +87,7 @@ "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-sourcemaps": "0.6.3", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 9896376d716..ef7cc40d4c5 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -44,7 +44,7 @@ "rollup": "^4.22.4", "rollup-plugin-esbuild": "6.2.1", "rollup-plugin-node-externals": "^8.0.0", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "dependencies": { "tslib": "2.8.1" diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index b98c83c9525..9b229c4d1e9 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -722,6 +722,16 @@ export enum TableCellBackgroundDisplayMode { Gradient = 'gradient', } +/** + * Whenever we add text wrapping, we should add all text wrapping options at once + */ +export interface TableWrapTextOptions { + /** + * if true, wrap the text content of the cell + */ + wrapText?: boolean; +} + /** * Sort by field state */ @@ -755,17 +765,15 @@ export const defaultTableFooterOptions: Partial = { /** * Auto mode table cell options */ -export interface TableAutoCellOptions { +export interface TableAutoCellOptions extends TableWrapTextOptions { type: TableCellDisplayMode.Auto; - wrapText?: boolean; } /** * Colored text cell options */ -export interface TableColorTextCellOptions { +export interface TableColorTextCellOptions extends TableWrapTextOptions { type: TableCellDisplayMode.ColorText; - wrapText?: boolean; } /** @@ -787,7 +795,7 @@ export interface TableImageCellOptions { /** * Show data links in the cell */ -export interface TableDataLinksCellOptions { +export interface TableDataLinksCellOptions extends TableWrapTextOptions { type: TableCellDisplayMode.DataLinks; } @@ -818,11 +826,14 @@ export interface TableSparklineCellOptions extends GraphFieldConfig { /** * Colored background cell options */ -export interface TableColoredBackgroundCellOptions { +export interface TableColoredBackgroundCellOptions extends TableWrapTextOptions { applyToRow?: boolean; mode?: TableCellBackgroundDisplayMode; type: TableCellDisplayMode.ColorBackground; - wrapText?: boolean; +} + +export interface TablePillCellOptions extends TableWrapTextOptions { + type: TableCellDisplayMode.Pill; } /** @@ -841,37 +852,6 @@ export enum TableCellHeight { */ export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); -/** - * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) - * Generally defines alignment, filtering capabilties, display options, etc. - */ -export interface TableFieldOptions { - align: FieldTextAlignment; - cellOptions: TableCellOptions; - /** - * This field is deprecated in favor of using cellOptions - */ - displayMode?: TableCellDisplayMode; - filterable?: boolean; - hidden?: boolean; // ?? default is missing or false ?? - /** - * Hides any header for a column, useful for columns that show some static content or buttons. - */ - hideHeader?: boolean; - inspect: boolean; - minWidth?: number; - width?: number; - /** - * Enables text wrapping for column headers - */ - wrapHeaderText?: boolean; -} - -export const defaultTableFieldOptions: Partial = { - align: 'auto', - inspect: false, -}; - /** * Use UTC/GMT timezone */ @@ -987,10 +967,37 @@ export enum ComparisonOperation { NEQ = 'neq', } -export interface TablePillCellOptions { - type: TableCellDisplayMode.Pill; +/** + * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) + * Generally defines alignment, filtering capabilties, display options, etc. + */ +export interface TableFieldOptions { + align: FieldTextAlignment; + cellOptions: TableCellOptions; + /** + * This field is deprecated in favor of using cellOptions + */ + displayMode?: TableCellDisplayMode; + filterable?: boolean; + hidden?: boolean; // ?? default is missing or false ?? + /** + * Hides any header for a column, useful for columns that show some static content or buttons. + */ + hideHeader?: boolean; + inspect: boolean; + minWidth?: number; + width?: number; + /** + * Enables text wrapping for column headers + */ + wrapHeaderText?: boolean; } +export const defaultTableFieldOptions: Partial = { + align: 'auto', + inspect: false, +}; + /** * A specific timezone from https://en.wikipedia.org/wiki/Tz_database */ diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index acf4a6b7480..0a15fb926b5 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -11,6 +11,12 @@ TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-backgr // or a gradient. TableCellBackgroundDisplayMode: "basic" | "gradient" @cuetsy(kind="enum",memberNames="Basic|Gradient") +// Whenever we add text wrapping, we should add all text wrapping options at once +TableWrapTextOptions: { + // if true, wrap the text content of the cell + wrapText?: bool +} @cuetsy(kind="interface") + // Sort by field state TableSortByFieldState: { // Sets the display name of the field to sort by @@ -31,14 +37,12 @@ TableFooterOptions: { // Auto mode table cell options TableAutoCellOptions: { type: TableCellDisplayMode & "auto" - wrapText?: bool -} @cuetsy(kind="interface") +} & TableWrapTextOptions @cuetsy(kind="interface") // Colored text cell options TableColorTextCellOptions: { type: TableCellDisplayMode & "color-text" - wrapText?: bool -} @cuetsy(kind="interface") +} & TableWrapTextOptions @cuetsy(kind="interface") // Json view cell options TableJsonViewCellOptions: { @@ -55,7 +59,7 @@ TableImageCellOptions: { // Show data links in the cell TableDataLinksCellOptions: { type: TableCellDisplayMode & "data-links" -} @cuetsy(kind="interface") +} & TableWrapTextOptions @cuetsy(kind="interface") // Show actions in the cell TableActionsCellOptions: { @@ -81,8 +85,11 @@ TableColoredBackgroundCellOptions: { type: TableCellDisplayMode & "color-background" mode?: TableCellBackgroundDisplayMode applyToRow?: bool - wrapText?: bool -} @cuetsy(kind="interface") +} & TableWrapTextOptions @cuetsy(kind="interface") + +TablePillCellOptions: { + type: TableCellDisplayMode & "pill" +} & TableWrapTextOptions @cuetsy(kind="interface") // Height of a table cell TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum") @@ -108,7 +115,3 @@ TableFieldOptions: { // Enables text wrapping for column headers wrapHeaderText?: bool } @cuetsy(kind="interface") - -TablePillCellOptions: { - type: TableCellDisplayMode & "pill" -} @cuetsy(kind="interface") diff --git a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts index 983129db72b..997afed4975 100644 --- a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts @@ -112,11 +112,11 @@ export const defaultCanvasElementOptions: Partial = { connections: [], }; +export interface CanvasTooltip { + mode: ui.TooltipDisplayMode; +} + export interface Options { - /** - * Enable infinite pan - */ - infinitePan: boolean; /** * Enable inline editing */ @@ -147,11 +147,19 @@ export interface Options { * Show all available element types */ showAdvancedTypes: boolean; + /** + * Controls tooltip options + */ + tooltip: CanvasTooltip; + /** + * Zoom to content + */ + zoomToContent: boolean; } export const defaultOptions: Partial = { - infinitePan: true, inlineEditing: true, panZoom: true, showAdvancedTypes: true, + zoomToContent: true, }; diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 574d940a658..e045f423bd5 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -43,7 +43,7 @@ "@testing-library/user-event": "14.6.1", "@types/jest": "^29.5.4", "@types/lodash": "4.17.20", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@types/react-virtualized-auto-sizer": "1.0.8", @@ -51,9 +51,9 @@ "@types/uuid": "10.0.0", "i18next-parser": "9.3.0", "jest": "^29.6.4", - "ts-jest": "29.2.5", + "ts-jest": "29.4.0", "ts-node": "10.9.2", - "typescript": "5.8.3" + "typescript": "5.9.2" }, "peerDependencies": { "@grafana/runtime": "10.4.0-pre" diff --git a/packages/grafana-test-utils/package.json b/packages/grafana-test-utils/package.json index 9a9f5998621..99eedba93d4 100644 --- a/packages/grafana-test-utils/package.json +++ b/packages/grafana-test-utils/package.json @@ -57,11 +57,11 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@swc/core": "1.10.12", + "@swc/core": "1.13.3", "@swc/jest": "^0.2.26", "@types/jest": "29.5.14", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "jest": "29.7.0", - "typescript": "5.8.3" + "typescript": "5.9.2" } } diff --git a/packages/grafana-test-utils/src/fixtures/folders.ts b/packages/grafana-test-utils/src/fixtures/folders.ts new file mode 100644 index 00000000000..7fc9bed9e20 --- /dev/null +++ b/packages/grafana-test-utils/src/fixtures/folders.ts @@ -0,0 +1,130 @@ +import { Chance } from 'chance'; + +import { DashboardsTreeItem, DashboardViewItem, UIDashboardViewItem } from '../types/browse-dashboards'; + +function wellFormedEmptyFolder( + seed = 1, + partial?: Partial> +): DashboardsTreeItem { + const random = Chance(seed); + + return { + item: { + kind: 'ui', + uiKind: 'empty-folder', + uid: random.guid(), + }, + level: 0, + isOpen: false, + ...partial, + }; +} + +function wellFormedDashboard( + seed = 1, + partial?: Partial>, + itemPartial?: Partial +): DashboardsTreeItem { + const random = Chance(seed); + + return { + item: { + kind: 'dashboard', + title: random.sentence({ words: 3 }), + uid: random.guid(), + tags: [random.word()], + ...itemPartial, + }, + level: 0, + isOpen: false, + ...partial, + }; +} + +function wellFormedFolder( + seed = 1, + partial?: Partial>, + itemPartial?: Partial +): DashboardsTreeItem { + const random = Chance(seed); + const uid = random.guid(); + + return { + item: { + kind: 'folder', + title: random.sentence({ words: 3 }), + uid, + url: `/dashboards/f/${uid}`, + ...itemPartial, + }, + level: 0, + isOpen: false, + ...partial, + }; +} + +export function treeViewersCanEdit() { + const [, { folderA, folderC }] = wellFormedTree(); + + return [ + [folderA, folderC], + { + folderA, + folderC, + }, + ] as const; +} + +export function wellFormedTree() { + let seed = 1; + + const folderA = wellFormedFolder(seed++); + const folderA_folderA = wellFormedFolder(seed++, { level: 1 }, { parentUID: folderA.item.uid }); + const folderA_folderB = wellFormedFolder(seed++, { level: 1 }, { parentUID: folderA.item.uid }); + const folderA_folderB_dashbdA = wellFormedDashboard(seed++, { level: 2 }, { parentUID: folderA_folderB.item.uid }); + const folderA_folderB_dashbdB = wellFormedDashboard(seed++, { level: 2 }, { parentUID: folderA_folderB.item.uid }); + const folderA_folderC = wellFormedFolder(seed++, { level: 1 }, { parentUID: folderA.item.uid }); + const folderA_folderC_dashbdA = wellFormedDashboard(seed++, { level: 2 }, { parentUID: folderA_folderC.item.uid }); + const folderA_folderC_dashbdB = wellFormedDashboard(seed++, { level: 2 }, { parentUID: folderA_folderC.item.uid }); + const folderA_dashbdD = wellFormedDashboard(seed++, { level: 1 }, { parentUID: folderA.item.uid }); + const folderB = wellFormedFolder(seed++); + const folderB_empty = wellFormedEmptyFolder(seed++); + const folderC = wellFormedFolder(seed++); + const dashbdD = wellFormedDashboard(seed++); + const dashbdE = wellFormedDashboard(seed++); + + return [ + [ + folderA, + folderA_folderA, + folderA_folderB, + folderA_folderB_dashbdA, + folderA_folderB_dashbdB, + folderA_folderC, + folderA_folderC_dashbdA, + folderA_folderC_dashbdB, + folderA_dashbdD, + folderB, + folderB_empty, + folderC, + dashbdD, + dashbdE, + ], + { + folderA, + folderA_folderA, + folderA_folderB, + folderA_folderB_dashbdA, + folderA_folderB_dashbdB, + folderA_folderC, + folderA_folderC_dashbdA, + folderA_folderC_dashbdB, + folderA_dashbdD, + folderB, + folderB_empty, + folderC, + dashbdD, + dashbdE, + }, + ] as const; +} diff --git a/packages/grafana-test-utils/src/fixtures/teams.ts b/packages/grafana-test-utils/src/fixtures/teams.ts new file mode 100644 index 00000000000..86c737fd7a7 --- /dev/null +++ b/packages/grafana-test-utils/src/fixtures/teams.ts @@ -0,0 +1,25 @@ +import { Chance } from 'chance'; + +const chance = new Chance('mock-teams'); + +export const MOCK_TEAMS = [ + { + metadata: { + name: chance.string({ length: 14, pool: 'abcdefghijklmnopqrstuvwxyz1234567890' }), + namespace: 'default', + resourceVersion: '1737038862000', + creationTimestamp: '2025-01-16T14:47:42Z', + labels: { + 'grafana.app/deprecatedInternalID': chance.integer({ min: 1, max: 1000 }).toString(), + }, + annotations: { + 'grafana.app/updatedTimestamp': '2025-01-16T14:47:42Z', + }, + }, + spec: { + title: 'Test Team', + email: 'foo@example.com', + }, + status: {}, + }, +]; diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index be4d730a128..3e626ba021b 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -1,5 +1,9 @@ import { HttpHandler } from 'msw'; -const allHandlers: HttpHandler[] = []; +import folderHandlers from './api/folders/handlers'; +import teamsHandlers from './api/teams/handlers'; +import appPlatformFolderHandlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; + +const allHandlers: HttpHandler[] = [...teamsHandlers, ...folderHandlers, ...appPlatformFolderHandlers]; export default allHandlers; diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts new file mode 100644 index 00000000000..103b816ce3a --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -0,0 +1,52 @@ +import { HttpResponse, http } from 'msw'; + +import { treeViewersCanEdit, wellFormedTree } from '../../../fixtures/folders'; + +const [mockTree] = wellFormedTree(); +const [mockTreeThatViewersCanEdit] = treeViewersCanEdit(); +const collator = new Intl.Collator(); + +const listFoldersHandler = () => + http.get('/api/folders', ({ request }) => { + const url = new URL(request.url); + const parentUid = url.searchParams.get('parentUid') ?? undefined; + const permission = url.searchParams.get('permission'); + + const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10); + const page = parseInt(url.searchParams.get('page') ?? '1', 10); + + const tree = permission === 'Edit' ? mockTreeThatViewersCanEdit : mockTree; + + // reconstruct a folder API response from the flat tree fixture + const folders = tree + .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUid) + .map((folder) => { + return { + uid: folder.item.uid, + title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", + }; + }) + .sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title + .slice(limit * (page - 1), limit * page); + + return HttpResponse.json(folders); + }); + +const getFolderHandler = () => + http.get('/api/folders/:uid', ({ params }) => { + const { uid } = params; + + const folder = mockTree.find((v) => v.item.uid === uid); + if (!folder) { + return HttpResponse.json({ message: 'folder not found', status: 'not-found' }, { status: 404 }); + } + + return HttpResponse.json({ + title: folder?.item.title, + uid: folder?.item.uid, + }); + }); + +const handlers = [listFoldersHandler(), getFolderHandler()]; + +export default handlers; diff --git a/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts b/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts new file mode 100644 index 00000000000..31d513a2263 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/api/teams/handlers.ts @@ -0,0 +1,53 @@ +import { HttpResponse, http } from 'msw'; + +import { MOCK_TEAMS } from '../../../fixtures/teams'; + +const k8sTeamToLegacyTeam = (k8sTeam: (typeof MOCK_TEAMS)[number]) => { + return { + name: k8sTeam.spec.title, + email: k8sTeam.spec.email, + id: Number(k8sTeam.metadata.labels['grafana.app/deprecatedInternalID']), + uid: k8sTeam.metadata.name, + orgId: 1, + externalUID: '', + isProvisioned: false, + avatarUrl: '', + memberCount: 0, + permission: 0, + }; +}; + +const searchTeamsHandler = () => + http.get('/api/teams/search', async ({ request }) => { + const url = new URL(request.url); + // TODO in future: pagination and mock querying + const page = url.searchParams.get('page') ?? 1; + const perPage = url.searchParams.get('perPage') ?? 1000; + + return HttpResponse.json({ + totalCount: MOCK_TEAMS.length, + teams: MOCK_TEAMS.map(k8sTeamToLegacyTeam), + page, + perPage, + }); + }); + +const createTeamHandler = () => + http.post('/api/teams', async ({ request }) => { + const body = await request.json(); + + if (!body.name) { + return HttpResponse.json({ message: 'bad request data' }, { status: 400 }); + } + + const existingTeam = MOCK_TEAMS.find((t) => t.spec.title === body.name); + + if (existingTeam) { + return HttpResponse.json({ message: 'Team name taken' }, { status: 409 }); + } + return HttpResponse.json({ message: 'Team created', teamId: 10, uid: 'aethyfifmhwcgd' }, { status: 200 }); + }); + +const handlers = [searchTeamsHandler(), createTeamHandler()]; + +export default handlers; diff --git a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts new file mode 100644 index 00000000000..43813d6f4f8 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts @@ -0,0 +1,52 @@ +import { Chance } from 'chance'; +import { HttpResponse, http } from 'msw'; + +import { wellFormedTree } from '../../../../fixtures/folders'; + +const [mockTree] = wellFormedTree(); + +type FilterArray = Array<(v: (typeof mockTree)[number]) => boolean>; + +const getSearchHandler = () => + http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/search', ({ request }) => { + const folderFilter = new URL(request.url).searchParams.get('folder') || null; + const typeFilter = new URL(request.url).searchParams.get('type') || null; + const response = mockTree + .filter((filterItem) => { + const filters: FilterArray = []; + if (folderFilter && folderFilter !== 'general') { + filters.push(({ item }) => item.kind === 'folder' && item.parentUID === folderFilter); + } + + if (folderFilter === 'general') { + filters.push(({ item }) => item.kind === 'folder' && item.parentUID === undefined); + } + + if (typeFilter) { + filters.push(({ item }) => item.kind === typeFilter); + } + + return filters.every((filterPredicate) => filterPredicate(filterItem)); + }) + + .map(({ item }) => { + const random = Chance(item.uid); + return { + resource: 'folders', + name: item.uid, + title: item.title, + field: { + // Generate mock deprecated IDs only in the mock handlers - not generating in + // mock data as it would require updating/tracking in the types as well + 'grafana.app/deprecatedInternalID': random.integer({ min: 1, max: 1000 }), + }, + }; + }); + + return HttpResponse.json({ + totalHits: response.length, + hits: response, + }); + }); + +export default [getSearchHandler()]; diff --git a/packages/grafana-test-utils/src/types/browse-dashboards.ts b/packages/grafana-test-utils/src/types/browse-dashboards.ts new file mode 100644 index 00000000000..5757b4a2ec0 --- /dev/null +++ b/packages/grafana-test-utils/src/types/browse-dashboards.ts @@ -0,0 +1,58 @@ +// FIXME: This file is a duplication of types within the core code +// Where should these live long term? +// @grafana/schema? +// New package @grafana/core? @grafana/types? + +enum ManagerKind { + Repo = 'repo', + Terraform = 'terraform', + Kubectl = 'kubectl', + Plugin = 'plugin', +} + +type DashboardViewItemKind = 'folder' | 'dashboard' | 'panel'; + +type DashboardViewItemWithUIItems = DashboardViewItem | UIDashboardViewItem; + +export interface DashboardsTreeItem { + item: T; + level: number; + isOpen: boolean; + parentUID?: string; +} + +export interface UIDashboardViewItem { + kind: 'ui'; + uiKind: 'empty-folder' | 'pagination-placeholder' | 'divider'; + uid: string; + // Optional title to make mock data easier to work with + title?: string; +} + +/** + * Type used in the folder view components + */ +export interface DashboardViewItem { + kind: DashboardViewItemKind; + uid: string; + title: string; + url?: string; + tags?: string[]; + + icon?: string; + + parentUID?: string; + /** @deprecated Not used in new Browse UI */ + parentTitle?: string; + /** @deprecated Not used in new Browse UI */ + parentKind?: string; + + // Used only for psuedo-folders, such as Starred or Recent + /** @deprecated Not used in new Browse UI */ + itemsUIDs?: string[]; + + // For enterprise sort options + sortMeta?: number | string; // value sorted by + sortMetaName?: string; // name of the value being sorted e.g. 'Views' + managedBy?: ManagerKind; +} diff --git a/packages/grafana-test-utils/src/unstable.ts b/packages/grafana-test-utils/src/unstable.ts index cb0ff5c3b54..8758e294464 100644 --- a/packages/grafana-test-utils/src/unstable.ts +++ b/packages/grafana-test-utils/src/unstable.ts @@ -1 +1,4 @@ -export {}; +import { wellFormedTree } from './fixtures/folders'; + +export const getFolderFixtures = wellFormedTree; +export { MOCK_TEAMS } from './fixtures/teams'; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 8738aab4d7c..dfb0c9754ea 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -66,7 +66,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/serialize": "1.3.3", - "@floating-ui/react": "0.27.14", + "@floating-ui/react": "0.27.15", "@grafana/data": "12.2.0-pre", "@grafana/e2e-selectors": "12.2.0-pre", "@grafana/faro-web-sdk": "^1.13.2", @@ -75,10 +75,10 @@ "@hello-pangea/dnd": "18.0.1", "@monaco-editor/react": "4.7.0", "@popperjs/core": "2.11.8", - "@react-aria/dialog": "3.5.27", - "@react-aria/focus": "3.20.5", - "@react-aria/overlays": "3.27.3", - "@react-aria/utils": "3.29.1", + "@react-aria/dialog": "3.5.28", + "@react-aria/focus": "3.21.0", + "@react-aria/overlays": "3.28.0", + "@react-aria/utils": "3.30.0", "@tanstack/react-virtual": "^3.5.1", "@types/jquery": "3.5.32", "@types/lodash": "4.17.20", @@ -166,7 +166,7 @@ "@types/is-hotkey": "0.1.10", "@types/jest": "29.5.14", "@types/mock-raf": "1.0.6", - "@types/node": "22.16.5", + "@types/node": "22.17.0", "@types/prismjs": "1.26.5", "@types/react": "18.3.18", "@types/react-color": "3.0.13", @@ -203,8 +203,8 @@ "sass-loader": "16.0.5", "storybook": "^8.6.2", "style-loader": "4.0.0", - "typescript": "5.8.3", - "webpack": "5.97.1" + "typescript": "5.9.2", + "webpack": "5.101.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.test.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.test.tsx new file mode 100644 index 00000000000..371dec5fec5 --- /dev/null +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ClickOutsideWrapper } from './ClickOutsideWrapper'; + +describe('ClickOutsideWrapper', () => { + it('should call callback when clicked outside', async () => { + let clickedOutside = false; + render( +
+ { + clickedOutside = true; + }} + > + Click Outside + + +
+ ); + + const button = screen.getByText('Click me'); + await userEvent.click(button); + expect(clickedOutside).toBe(true); + }); +}); diff --git a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx index 015aada0881..4a3b9e9b88a 100644 --- a/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -1,62 +1,51 @@ -import { PureComponent, createRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import * as React from 'react'; export interface Props { - /** - * Callback to trigger when clicking outside of current element occurs. - */ + /** Callback to trigger when clicking outside of current element occurs. */ onClick: () => void; - /** - * Runs the 'onClick' function when pressing a key outside of the current element. Defaults to true. - */ - includeButtonPress: boolean; + /** Runs the 'onClick' function when pressing a key outside of the current element. Defaults to true. */ + includeButtonPress?: boolean; /** Object to attach the click event listener to. */ - parent: Window | Document; - /** - * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. Defaults to false. - */ + parent?: Window | Document; + /** https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener. Defaults to false. */ useCapture?: boolean; + children: React.ReactNode; } -interface State { - hasEventListener: boolean; -} +export function ClickOutsideWrapper({ + includeButtonPress = true, + parent = window, + useCapture = false, + onClick, + children, +}: Props) { + const wrapperRef = useRef(null); + const onOutsideClick = useCallback( + (event: Event) => { + const domNode = wrapperRef.current; -export class ClickOutsideWrapper extends PureComponent, State> { - static defaultProps = { - includeButtonPress: true, - parent: typeof window !== 'undefined' ? window : undefined, - useCapture: false, - }; - myRef = createRef(); - state = { - hasEventListener: false, - }; + if (!domNode || (event.target instanceof Node && !domNode.contains(event.target))) { + onClick(); + } + }, + [onClick] + ); - componentDidMount() { - this.props.parent.addEventListener('click', this.onOutsideClick, this.props.useCapture); - if (this.props.includeButtonPress) { + useEffect(() => { + parent.addEventListener('click', onOutsideClick, useCapture); + if (includeButtonPress) { // Use keyup since keydown already has an event listener on window - this.props.parent.addEventListener('keyup', this.onOutsideClick, this.props.useCapture); + parent.addEventListener('keyup', onOutsideClick, useCapture); } - } - componentWillUnmount() { - this.props.parent.removeEventListener('click', this.onOutsideClick, this.props.useCapture); - if (this.props.includeButtonPress) { - this.props.parent.removeEventListener('keyup', this.onOutsideClick, this.props.useCapture); - } - } + return () => { + parent.removeEventListener('click', onOutsideClick, useCapture); + if (includeButtonPress) { + parent.removeEventListener('keyup', onOutsideClick, useCapture); + } + }; + }, [includeButtonPress, onOutsideClick, parent, useCapture]); - onOutsideClick: EventListener = (event) => { - const domNode = this.myRef.current; - - if (!domNode || (event.target instanceof Node && !domNode.contains(event.target))) { - this.props.onClick(); - } - }; - - render() { - return
{this.props.children}
; - } + return
{children}
; } diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx index b7249e88b2b..55b9d3e19ba 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx @@ -1,54 +1,18 @@ import { render, RenderResult } from '@testing-library/react'; -import { DataFrame, Field, FieldType, GrafanaTheme2, MappingType, createTheme } from '@grafana/data'; -import { TableCellDisplayMode, TablePillCellOptions } from '@grafana/schema'; +import { Field, FieldType, MappingType, createTheme } from '@grafana/data'; -import { mockThemeContext } from '../../../../themes/ThemeContext'; - -import { PillCell, getStyles } from './PillCell'; +import { PillCell } from './PillCell'; describe('PillCell', () => { - let pillClass: string; - let restoreThemeContext: () => void; + const theme = createTheme(); - beforeEach(() => { - pillClass = getStyles(createTheme()).pill; - restoreThemeContext = mockThemeContext(createTheme()); - }); - - afterEach(() => { - restoreThemeContext(); - }); - - const mockCellOptions: TablePillCellOptions = { - type: TableCellDisplayMode.Pill, - }; - - const mockField: Field = { + const fieldWithValues = (values: unknown[]): Field => ({ name: 'test', type: FieldType.string, - values: [], + values: values, config: {}, - }; - - const mockFrame: DataFrame = { - name: 'test', - fields: [mockField], - length: 1, - }; - - const defaultProps = { - field: mockField, - justifyContent: 'flex-start' as const, - cellOptions: mockCellOptions, - rowIdx: 0, - frame: mockFrame, - height: 30, - width: 100, - theme: {} as GrafanaTheme2, - cellInspect: false, - showFilters: false, - }; + }); const ser = new XMLSerializer(); @@ -60,88 +24,84 @@ describe('PillCell', () => { // one class for lightTextPill, darkTextPill describe('Color by hash (classic palette)', () => { - const props = { ...defaultProps }; - it('single value', () => { expectHTML( - render(), - `value1` + render(), + `value1` ); }); it('empty string', () => { - expectHTML(render(), ''); + expectHTML(render(), ''); }); - // it('null', () => { - // expectHTML( - // render(), - // 'value1' - // ); - // }); + it('null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); it('CSV values', () => { expectHTML( - render(), + render(), ` - value1 - value2 - value3 + value1 + value2 + value3 ` ); }); it('JSON array values', () => { expectHTML( - render(), + render(), ` - value1 - value2 - value3 + value1 + value2 + value3 ` ); }); - - // TODO: handle null values? }); describe('Color by value mappings', () => { - const field: Field = { - ...mockField, - config: { - ...mockField.config, - mappings: [ - { - type: MappingType.ValueToText, - options: { - success: { color: '#00FF00' }, - error: { color: '#FF0000' }, - warning: { color: '#FFFF00' }, - }, - }, - ], - }, - display: (value: unknown) => ({ - text: String(value), - color: - value === 'success' ? '#00FF00' : value === 'error' ? '#FF0000' : value === 'warning' ? '#FFFF00' : '#FF780A', - numeric: 0, - }), - }; - - const props = { - ...defaultProps, - field, - }; - it('CSV values', () => { + const mockField = fieldWithValues(['success,error,warning,unknown']); + const field = { + ...mockField, + config: { + ...mockField.config, + mappings: [ + { + type: MappingType.ValueToText, + options: { + success: { color: '#00FF00' }, + error: { color: '#FF0000' }, + warning: { color: '#FFFF00' }, + }, + }, + ], + }, + display: (value: unknown) => ({ + text: String(value), + color: + value === 'success' + ? '#00FF00' + : value === 'error' + ? '#FF0000' + : value === 'warning' + ? '#FFFF00' + : '#FF780A', + numeric: 0, + }), + } satisfies Field; + expectHTML( - render(), + render(), ` - success - error - warning - unknown + success + error + warning + unknown ` ); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx index c1ca23b7f69..35b759aa224 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx @@ -1,4 +1,3 @@ -import { css } from '@emotion/css'; import { useMemo } from 'react'; import { @@ -8,11 +7,36 @@ import { Field, getColorByStringHash, FALLBACK_COLOR, + fieldColorModeRegistry, } from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema'; -import { useStyles2, useTheme2 } from '../../../../themes/ThemeContext'; -import { TableCellRendererProps } from '../types'; +import { PillCellProps, TableCellValue } from '../types'; + +export function PillCell({ rowIdx, field, theme }: PillCellProps) { + const value = field.values[rowIdx]; + const pills: Pill[] = useMemo(() => { + const pillValues = inferPills(value); + return pillValues.length > 0 ? createPills(pillValues, field, theme) : []; + }, [value, field, theme]); + + if (pills.length === 0) { + return null; + } + + return pills.map((pill) => ( + + {pill.value} + + )); +} interface Pill { value: string; @@ -21,6 +45,9 @@ interface Pill { color: string; } +const SPLIT_RE = /\s*,\s*/; +const TRANSPARENT = 'rgba(0,0,0,0)'; + function createPills(pillValues: string[], field: Field, theme: GrafanaTheme2): Pill[] { return pillValues.map((pill, index) => { const bgColor = getPillColor(pill, field, theme); @@ -34,38 +61,13 @@ function createPills(pillValues: string[], field: Field, theme: GrafanaTheme2): }); } -export function PillCell({ value, field }: TableCellRendererProps) { - const styles = useStyles2(getStyles); - const theme = useTheme2(); - - const pills: Pill[] = useMemo(() => { - const pillValues = inferPills(String(value)); - return createPills(pillValues, field, theme); - }, [value, field, theme]); - - return pills.map((pill) => ( - - {pill.value} - - )); -} - -const SPLIT_RE = /\s*,\s*/; -const TRANSPARENT = 'rgba(0,0,0,0)'; - -export function inferPills(value: string): string[] { - if (value === '') { +export function inferPills(rawValue: TableCellValue): string[] { + if (rawValue === '' || rawValue == null) { return []; } + const value = String(rawValue); + if (value[0] === '[') { try { return JSON.parse(value); @@ -77,6 +79,7 @@ export function inferPills(value: string): string[] { return value.trim().split(SPLIT_RE); } +// FIXME: this does not yet support "shades of a color" function getPillColor(value: string, field: Field, theme: GrafanaTheme2): string { const cfg = field.config; @@ -88,19 +91,14 @@ function getPillColor(value: string, field: Field, theme: GrafanaTheme2): string return theme.visualization.getColorByName(cfg.color?.fixedColor ?? FALLBACK_COLOR); } - // TODO: instead of classicColors we need to pull colors from theme, same way as FieldColorModeId.PaletteClassicByName (see fieldColor.ts) - return getColorByStringHash(classicColors, value); -} + let colors = classicColors; + const configuredColor = cfg.color; + if (configuredColor) { + const mode = fieldColorModeRegistry.get(configuredColor.mode); + if (typeof mode?.getColors === 'function') { + colors = mode.getColors(theme); + } + } -export const getStyles = (theme: GrafanaTheme2) => ({ - pill: css({ - display: 'inline-block', - padding: theme.spacing(0.25, 0.75), - marginInlineEnd: theme.spacing(0.5), - marginBlock: theme.spacing(0.5), - borderRadius: theme.shape.radius.default, - fontSize: theme.typography.bodySmall.fontSize, - lineHeight: theme.typography.bodySmall.lineHeight, - whiteSpace: 'nowrap', - }), -}); + return getColorByStringHash(colors, value); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx index c66ed4be81c..746ca7027d1 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx @@ -58,7 +58,7 @@ export function TableCellActions(props: TableCellActionsProps) { {showFilters && ( <> { onCellFilterAdded?.({ @@ -69,7 +69,7 @@ export function TableCellActions(props: TableCellActionsProps) { }} /> { onCellFilterAdded?.({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 40cd49278d3..8d6c9bb5ef8 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -367,14 +367,15 @@ export function TableNG(props: TableNGProps) { case TableCellDisplayMode.ColorText: case TableCellDisplayMode.DataLinks: case TableCellDisplayMode.JSONView: + case TableCellDisplayMode.Pill: cellClass = getCellStyles( theme, + cellType, textAlign, shouldWrap, shouldOverflow, canBeColorized, - isMonospace, - cellType === TableCellDisplayMode.DataLinks + isMonospace ); break; } @@ -821,6 +822,7 @@ const getGridStyles = ( border: 'none', '.rdg-cell': { + padding: TABLE.CELL_PADDING, '&:last-child': { borderInlineEnd: 'none', }, @@ -843,8 +845,6 @@ const getGridStyles = ( '.rdg-header-row, .rdg-summary-row': { '.rdg-cell': { zIndex: theme.zIndex.tooltip - 1, - paddingInline: TABLE.CELL_PADDING, - paddingBlock: TABLE.CELL_PADDING, }, }, }), @@ -941,35 +941,36 @@ const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.Just const getCellStyles = ( theme: GrafanaTheme2, + cellType: TableCellDisplayMode, textAlign: TextAlign, shouldWrap: boolean, shouldOverflow: boolean, isColorized: boolean, - isMonospace: boolean, - // TODO: replace this with cellTypeStyles: TemplateStringsArray object - isLinkCell: boolean + isMonospace: boolean ) => css({ display: 'flex', alignItems: 'center', textAlign, justifyContent: getJustifyContent(textAlign), - paddingInline: TABLE.CELL_PADDING, minHeight: '100%', backgroundClip: 'padding-box !important', // helps when cells have a bg color + ...(shouldWrap && { whiteSpace: isMonospace ? 'pre' : 'pre-line' }), ...(isMonospace && { fontFamily: 'monospace' }), - // should omit if no cell actions, and no shouldOverflow '&:hover, &[aria-selected=true]': { '.table-cell-actions': { display: 'flex', }, ...(shouldOverflow && { - whiteSpace: 'pre-line', + zIndex: theme.zIndex.tooltip - 2, + whiteSpace: isMonospace ? 'pre' : 'pre-line', height: 'fit-content', minWidth: 'fit-content', - ...(isMonospace && { whiteSpace: 'pre' }), + ...(cellType === TableCellDisplayMode.Pill && { + flexWrap: 'wrap', + }), }), }, @@ -989,21 +990,39 @@ const getCellStyles = ( }), }, - ...(isLinkCell && { + ...(cellType === TableCellDisplayMode.DataLinks && { + ...(shouldWrap && { + flexDirection: 'column', + justifyContent: 'center', + alignItems: getJustifyContent(textAlign), + }), '> a': { - // display: 'inline', // textWrap ? 'block' : 'inline', + flexWrap: 'nowrap', + ...(!shouldWrap && { + paddingInline: theme.spacing(0.5), + borderRight: `2px solid ${theme.colors.border.medium}`, + '&:first-child': { + paddingInlineStart: 0, + }, + '&:last-child': { + paddingInlineEnd: 0, + borderRight: 'none', + }, + }), + }, + }), + + ...(cellType === TableCellDisplayMode.Pill && { + display: 'inline-flex', + gap: theme.spacing(0.5), + flexWrap: shouldWrap ? 'wrap' : 'nowrap', + '> span': { + display: 'flex', + padding: theme.spacing(0.25, 0.75), + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, whiteSpace: 'nowrap', - paddingInline: theme.spacing(1), - borderRight: `2px solid ${theme.colors.border.medium}`, - - '&:first-of-type': { - paddingInlineStart: 0, - }, - - '&:last-of-type': { - borderRight: 'none', - paddingInlineEnd: 0, - }, }, }), }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts index f7a1a1b858a..ce378961589 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -490,24 +490,26 @@ describe('TableNG hooks', () => { const { fields } = setupData(); + let modifiedFields = fields.map((field) => { + if (field.name === 'name') { + return { + ...field, + name: 'Longer name that needs wrapping', + config: { + ...field.config, + custom: { + ...field.config?.custom, + wrapHeaderText: true, + }, + }, + }; + } + return field; + }); + renderHook(() => { return useHeaderHeight({ - fields: fields.map((field) => { - if (field.name === 'name') { - return { - ...field, - name: 'Longer name that needs wrapping', - config: { - ...field.config, - custom: { - ...field.config?.custom, - wrapHeaderText: true, - }, - }, - }; - } - return field; - }), + fields: modifiedFields, columnWidths: [100, 100, 100], enabled: true, typographyCtx: { ...typographyCtx, wrappedCount: countFn }, @@ -516,27 +518,29 @@ describe('TableNG hooks', () => { }); }); - expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86); + expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86, modifiedFields[0], -1); + + modifiedFields = fields.map((field) => { + if (field.name === 'name') { + return { + ...field, + name: 'Longer name that needs wrapping', + config: { + ...field.config, + custom: { + ...field.config?.custom, + filterable: true, + wrapHeaderText: true, + }, + }, + }; + } + return field; + }); renderHook(() => { return useHeaderHeight({ - fields: fields.map((field) => { - if (field.name === 'name') { - return { - ...field, - name: 'Longer name that needs wrapping', - config: { - ...field.config, - custom: { - ...field.config?.custom, - filterable: true, - wrapHeaderText: true, - }, - }, - }; - } - return field; - }), + fields: modifiedFields, columnWidths: [100, 100, 100], enabled: true, typographyCtx: { ...typographyCtx, wrappedCount: countFn }, @@ -545,7 +549,7 @@ describe('TableNG hooks', () => { }); }); - expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26); + expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26, modifiedFields[0], -1); }); }); @@ -764,7 +768,12 @@ describe('TableNG hooks', () => { expect(result.current(rows[0])).toEqual(expect.any(Number)); - expect(estimateLinesFn).toHaveBeenCalledWith('Annie Lennox', 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT); + expect(estimateLinesFn).toHaveBeenCalledWith( + 'Annie Lennox', + 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT, + fieldsWithWrappedText[0], + 0 + ); }); }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 70a790d9133..5c349bdd1ab 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -3,7 +3,7 @@ import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-gr import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; -import { TableColumnResizeActionCallback } from '../types'; +import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; import { TABLE } from './constants'; import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types'; @@ -15,6 +15,7 @@ import { getRowHeight, buildHeaderLineCounters, buildRowLineCounters, + getCellOptions, } from './utils'; // Helper function to get displayed value @@ -437,7 +438,15 @@ export function useRowHeight({ defaultHeight, lineCounters, TABLE.LINE_HEIGHT, - TABLE.CELL_PADDING * 2 + (field, numLines) => { + // Pill cells have vertical padding between each row + if (getCellOptions(field).type === TableCellDisplayMode.Pill) { + return TABLE.CELL_PADDING * (numLines - 1) + TABLE.CELL_PADDING * 2; + } + + // default vertical padding for cells + return TABLE.CELL_PADDING * 2; + } ); } return result; diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 1d736974189..d28bab7b668 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -248,6 +248,12 @@ export interface ActionCellProps { getActions: GetActionsFunctionLocal; } +export interface PillCellProps { + theme: GrafanaTheme2; + field: Field; + rowIdx: number; +} + // Comparator for sorting table values export type Comparator = (a: TableCellValue, b: TableCellValue) => number; @@ -264,13 +270,14 @@ export interface ScrollPosition { export interface TypographyCtx { ctx: CanvasRenderingContext2D; - font: string; + fontFamily: string; + letterSpacing: number; avgCharWidth: number; estimateLines: LineCounter; wrappedCount: LineCounter; } -export type LineCounter = (value: unknown, width: number) => number; +export type LineCounter = (value: unknown, width: number, field: Field, rowIdx: number) => number; export interface LineCounterEntry { /** * given a values and the available width, returns the line count for that value diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index e86de2a7009..4dd20cac215 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -5,6 +5,7 @@ import { createTheme, DataFrame, DataFrameWithValue, + DataLink, DisplayValue, Field, FieldType, @@ -40,23 +41,17 @@ import { createTypographyContext, applySort, SINGLE_LINE_ESTIMATE_THRESHOLD, + wrapUwrapCount, + getDataLinksCounter, + getPillLineCounter, } from './utils'; describe('TableNG utils', () => { describe('alignment', () => { it.each(['left', 'center', 'right'] as const)('should return "%s" when configured', (align) => { - expect( - getAlignment({ - name: 'Value', - type: FieldType.string, - values: [], - config: { - custom: { - align, - }, - }, - }) - ).toBe(align); + expect(getAlignment({ name: 'Value', type: FieldType.string, values: [], config: { custom: { align } } })).toBe( + align + ); }); it.each([ @@ -65,16 +60,7 @@ describe('TableNG utils', () => { { type: FieldType.boolean, align: 'left' }, { type: FieldType.time, align: 'left' }, ])('should return "$align" for field type $type by default', ({ type, align }) => { - expect( - getAlignment({ - name: 'Test', - type, - values: [], - config: { - custom: {}, - }, - }) - ).toBe(align); + expect(getAlignment({ name: 'Test', type, values: [], config: { custom: {} } })).toBe(align); }); it.each([ @@ -91,17 +77,7 @@ describe('TableNG utils', () => { name: 'Test', type: FieldType.number, values: [], - config: { - custom: { - ...(cellType !== undefined - ? { - cellOptions: { - type: cellType, - }, - } - : {}), - }, - }, + config: { custom: { ...(cellType !== undefined ? { cellOptions: { type: cellType } } : {}) } }, }) ).toBe(align); }); @@ -122,34 +98,17 @@ describe('TableNG utils', () => { colors: { isDark: true, mode: 'dark', - primary: { - text: '#FFFFFF', - main: '#FF0000', - }, - background: { - canvas: '#000000', - primary: '#111111', - }, - text: { - primary: '#FFFFFF', - }, - action: { - hover: '#FF0000', - }, + primary: { text: '#FFFFFF', main: '#FF0000' }, + background: { canvas: '#000000', primary: '#111111' }, + text: { primary: '#FFFFFF' }, + action: { hover: '#FF0000' }, }, } as unknown as GrafanaTheme2; it('should handle color background mode', () => { - const field = { - type: TableCellDisplayMode.ColorBackground as const, - mode: TableCellBackgroundDisplayMode.Basic, - }; + const field = { type: TableCellDisplayMode.ColorBackground as const, mode: TableCellBackgroundDisplayMode.Basic }; - const displayValue = { - text: '100', - numeric: 100, - color: '#ff0000', - }; + const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; const colors = getCellColors(theme, field, displayValue); expect(colors.bgColor).toBe('rgb(255, 0, 0)'); @@ -162,11 +121,7 @@ describe('TableNG utils', () => { mode: TableCellBackgroundDisplayMode.Gradient, }; - const displayValue = { - text: '100', - numeric: 100, - color: '#ff0000', - }; + const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; const colors = getCellColors(theme, field, displayValue); expect(colors.bgColor).toBe('linear-gradient(120deg, rgb(255, 54, 36), #ff0000)'); @@ -185,12 +140,7 @@ describe('TableNG utils', () => { const records = frameToRecords(frame); expect(records).toHaveLength(2); - expect(records[0]).toEqual({ - __depth: 0, - __index: 0, - time: 1, - value: 10, - }); + expect(records[0]).toEqual({ __depth: 0, __index: 0, time: 1, value: 10 }); }); }); @@ -203,36 +153,22 @@ describe('TableNG utils', () => { config: {}, values: [1, 22, 333, 4444], // No state property initially - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - }), + display: (value: unknown) => ({ text: String(value), numeric: Number(value) }), }; // Create a display value - const displayValue: DisplayValue = { - text: '1', - numeric: 1, - }; + const displayValue: DisplayValue = { text: '1', numeric: 1 }; // Call getAlignmentFactor with the first row const result = getAlignmentFactor(field, displayValue, 0); // Verify the result has the text property - expect(result).toEqual( - expect.objectContaining({ - text: '1', - }) - ); + expect(result).toEqual(expect.objectContaining({ text: '1' })); // Verify that field.state was created and contains the alignment factor expect(field.state).toBeDefined(); expect(field.state?.alignmentFactors).toBeDefined(); - expect(field.state?.alignmentFactors).toEqual( - expect.objectContaining({ - text: '1', - }) - ); + expect(field.state?.alignmentFactors).toEqual(expect.objectContaining({ text: '1' })); }); it('should update alignment factor when a longer value is found', () => { @@ -242,39 +178,21 @@ describe('TableNG utils', () => { type: FieldType.number, config: {}, values: [1, 22, 333, 4444], - state: { - alignmentFactors: { - text: '1', - }, - }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - }), + state: { alignmentFactors: { text: '1' } }, + display: (value: unknown) => ({ text: String(value), numeric: Number(value) }), }; // Create a display value that is longer than the existing alignment factor - const displayValue: DisplayValue = { - text: '4444', - numeric: 4444, - }; + const displayValue: DisplayValue = { text: '4444', numeric: 4444 }; // Call getAlignmentFactor const result = getAlignmentFactor(field, displayValue, 3); // Verify the result is updated to the longer value - expect(result).toEqual( - expect.objectContaining({ - text: '4444', - }) - ); + expect(result).toEqual(expect.objectContaining({ text: '4444' })); // Verify that field.state.alignmentFactors was updated - expect(field.state?.alignmentFactors).toEqual( - expect.objectContaining({ - text: '4444', - }) - ); + expect(field.state?.alignmentFactors).toEqual(expect.objectContaining({ text: '4444' })); }); it('should not update alignment factor when a shorter value is found', () => { @@ -284,39 +202,21 @@ describe('TableNG utils', () => { type: FieldType.number, config: {}, values: [1, 22, 333, 4444], - state: { - alignmentFactors: { - text: '4444', - }, - }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - }), + state: { alignmentFactors: { text: '4444' } }, + display: (value: unknown) => ({ text: String(value), numeric: Number(value) }), }; // Create a display value that is shorter than the existing alignment factor - const displayValue: DisplayValue = { - text: '1', - numeric: 1, - }; + const displayValue: DisplayValue = { text: '1', numeric: 1 }; // Call getAlignmentFactor const result = getAlignmentFactor(field, displayValue, 0); // Verify the result is still the longer value - expect(result).toEqual( - expect.objectContaining({ - text: '4444', - }) - ); + expect(result).toEqual(expect.objectContaining({ text: '4444' })); // Verify that field.state.alignmentFactors was not changed - expect(field.state?.alignmentFactors).toEqual( - expect.objectContaining({ - text: '4444', - }) - ); + expect(field.state?.alignmentFactors).toEqual(expect.objectContaining({ text: '4444' })); }); it('should add alignment factor to existing field state', () => { @@ -334,38 +234,24 @@ describe('TableNG utils', () => { // Or if noValue is a valid property: // noValue: true }, - display: (value: unknown) => ({ - text: String(value), - numeric: Number(value), - }), + display: (value: unknown) => ({ text: String(value), numeric: Number(value) }), }; // Create a display value - const displayValue: DisplayValue = { - text: '1', - numeric: 1, - }; + const displayValue: DisplayValue = { text: '1', numeric: 1 }; // Call getAlignmentFactor with the first row const result = getAlignmentFactor(field, displayValue, 0); // Verify the result has the text property - expect(result).toEqual( - expect.objectContaining({ - text: '1', - }) - ); + expect(result).toEqual(expect.objectContaining({ text: '1' })); // Verify that field.state was preserved and alignment factor was added expect(field.state).toBeDefined(); // Check for the valid property we used expect(field.state?.calcs).toBeDefined(); expect(field.state?.alignmentFactors).toBeDefined(); - expect(field.state?.alignmentFactors).toEqual( - expect.objectContaining({ - text: '1', - }) - ); + expect(field.state?.alignmentFactors).toEqual(expect.objectContaining({ text: '1' })); }); it.todo('alignmentFactor.text = displayValue.text;'); @@ -398,11 +284,7 @@ describe('TableNG utils', () => { ]; const result = getColumnTypes(fields); - expect(result).toEqual({ - name: FieldType.string, - age: FieldType.number, - active: FieldType.boolean, - }); + expect(result).toEqual({ name: FieldType.string, age: FieldType.number, active: FieldType.boolean }); }); it('should recursively build column types when nested fields are present', () => { @@ -448,20 +330,13 @@ describe('TableNG utils', () => { const frame: DataFrame = { fields: [ { type: FieldType.string, name: 'stringCol', config: {}, values: [] }, - { - type: FieldType.nestedFrames, - name: 'nestedCol', - config: {}, - values: [], - }, + { type: FieldType.nestedFrames, name: 'nestedCol', config: {}, values: [] }, ], length: 0, name: 'test', }; - expect(getColumnTypes(frame.fields)).toEqual({ - stringCol: FieldType.string, - }); + expect(getColumnTypes(frame.fields)).toEqual({ stringCol: FieldType.string }); }); }); @@ -557,18 +432,12 @@ describe('TableNG utils', () => { describe('migrateTableDisplayModeToCellOptions', () => { it('should migrate basic to gauge mode', () => { const result = migrateTableDisplayModeToCellOptions(TableCellDisplayMode.BasicGauge); - expect(result).toEqual({ - type: TableCellDisplayMode.Gauge, - mode: BarGaugeDisplayMode.Basic, - }); + expect(result).toEqual({ type: TableCellDisplayMode.Gauge, mode: BarGaugeDisplayMode.Basic }); }); it('should migrate gradient-gauge to gauge mode with gradient', () => { const result = migrateTableDisplayModeToCellOptions(TableCellDisplayMode.GradientGauge); - expect(result).toEqual({ - type: TableCellDisplayMode.Gauge, - mode: BarGaugeDisplayMode.Gradient, - }); + expect(result).toEqual({ type: TableCellDisplayMode.Gauge, mode: BarGaugeDisplayMode.Gradient }); }); it('should migrate color-background to color background with gradient', () => { @@ -581,20 +450,13 @@ describe('TableNG utils', () => { it('should handle other display modes', () => { const result = migrateTableDisplayModeToCellOptions(TableCellDisplayMode.ColorText); - expect(result).toEqual({ - type: TableCellDisplayMode.ColorText, - }); + expect(result).toEqual({ type: TableCellDisplayMode.ColorText }); }); }); describe('getCellOptions', () => { it('should return default options when no custom config is provided', () => { - const field: Field = { - name: 'test', - type: FieldType.string, - config: {}, - values: [], - }; + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: [] }; const options = getCellOptions(field); @@ -607,35 +469,21 @@ describe('TableNG utils', () => { name: 'test', type: FieldType.string, config: { - custom: { - cellOptions: { - type: TableCellDisplayMode.ColorText, - inspectEnabled: false, - wrapText: true, - }, - }, + custom: { cellOptions: { type: TableCellDisplayMode.ColorText, inspectEnabled: false, wrapText: true } }, }, values: [], }; const options = getCellOptions(field); - expect(options).toEqual({ - type: TableCellDisplayMode.ColorText, - inspectEnabled: false, - wrapText: true, - }); + expect(options).toEqual({ type: TableCellDisplayMode.ColorText, inspectEnabled: false, wrapText: true }); }); it('should handle legacy displayMode property', () => { const field: Field = { name: 'test', type: FieldType.string, - config: { - custom: { - displayMode: 'color-background', - }, - }, + config: { custom: { displayMode: 'color-background' } }, values: [], }; @@ -649,14 +497,7 @@ describe('TableNG utils', () => { const field: Field = { name: 'test', type: FieldType.string, - config: { - custom: { - displayMode: 'color-background', - cellOptions: { - type: TableCellDisplayMode.ColorText, - }, - }, - }, + config: { custom: { displayMode: 'color-background', cellOptions: { type: TableCellDisplayMode.ColorText } } }, values: [], }; @@ -689,13 +530,7 @@ describe('TableNG utils', () => { const field: Field = { name: 'test', type: FieldType.string, - config: { - custom: { - cellOptions: { - type: TableCellDisplayMode.JSONView, - }, - }, - }, + config: { custom: { cellOptions: { type: TableCellDisplayMode.JSONView } } }, values: [], }; @@ -707,12 +542,7 @@ describe('TableNG utils', () => { describe('getCellLinks', () => { it('should return undefined when field has no getLinks function', () => { - const field: Field = { - name: 'test', - type: FieldType.string, - config: {}, - values: ['value'], - }; + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['value'] }; const links = getCellLinks(field, 0); expect(links).toEqual(undefined); @@ -987,36 +817,112 @@ describe('TableNG utils', () => { // actually executed the JS correctly. If you called `count` with a sensible value and width, // it wouldn't give you a very reasonable answer in Jest's DOM environment for some reason. it('creates the context using uwrap', () => { + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] }; const ctx = createTypographyContext(14, 'sans-serif', 0.15); + expect(ctx).toEqual( expect.objectContaining({ - font: '14px sans-serif', ctx: expect.any(CanvasRenderingContext2D), + fontFamily: 'sans-serif', + letterSpacing: 0.15, wrappedCount: expect.any(Function), estimateLines: expect.any(Function), avgCharWidth: expect.any(Number), }) ); - expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number)); - expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number)); + expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100, field, 0)).toEqual( + expect.any(Number) + ); + expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100, field, 0)).toEqual( + expect.any(Number) + ); + }); + }); + + describe('wrapUwrapCount', () => { + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] }; + + it('wraps the uwrap count function', () => { + const wrappedCount = wrapUwrapCount(jest.fn(() => 2)); + expect(wrappedCount('test string', 100, field, 0)).toBe(2); + }); + + it('returns 1 for null or undefined values', () => { + const wrappedCount = wrapUwrapCount(jest.fn(() => 2)); + expect(wrappedCount(null, 100, field, 0)).toBe(1); + expect(wrappedCount(undefined, 100, field, 0)).toBe(1); }); }); describe('getTextLineEstimator', () => { const counter = getTextLineEstimator(10); + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: ['foo', 'bar', 'baz'] }; it('returns -1 if there are no strings or dashes within the string', () => { - expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5)).toBe(-1); + expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5, field, 0)).toBe(-1); }); it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => { - expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200)).toBe(2.5); + expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200, field, 0)).toBe(2.5); + }); + }); + + describe('getDataLinksCounter', () => { + it('counts number of valid links using getCellLinks', () => { + const field: Field = { + name: 'test', + type: FieldType.string, + config: { + links: [ + { title: 'Link 1', url: 'http://example.com/1' }, + { title: 'Invalid Link' } as DataLink, // No href or onClick + { + title: 'Link w', + url: 'asdf', + onClick: jest.fn(() => {}), + }, + ], + }, + values: ['value1'], + }; + + const counter = getDataLinksCounter(); + expect(counter('my value', 100, field, 0)).toBe(2); + }); + }); + + describe('getPillLineCounter', () => { + it('counts up the number of lines using the pill measuring method', () => { + const counter = getPillLineCounter(jest.fn((str) => str.length * 5)); + expect(counter('tag1,tag2', 100, {} as Field, 0)).toBe(1); + expect(counter('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0)).toBe(3); + }); + + it('returns 0 if value is null', () => { + const counter = getPillLineCounter(jest.fn((str) => str.length * 5)); + expect(counter(null, 100, {} as Field, 0)).toBe(0); + }); + + it('returns 0 if no pills are inferred', () => { + const counter = getPillLineCounter(jest.fn((str) => str.length * 5)); + expect(counter('', 100, {} as Field, 0)).toBe(0); + }); + + it('caches the width measurement for the same value', () => { + const widthMeasurement = jest.fn((str) => str.length * 5); + const counter = getPillLineCounter(widthMeasurement); + counter('tag1,tag2,tag3,tag4,tag5,tag6', 100, {} as Field, 0); + counter('tag1,tag2', 100, {} as Field, 0); + counter('tag2', 200, {} as Field, 0); + counter('tag2,tag3,tag2,tag4,tag4,tag2,tag5', 300, {} as Field, 0); + expect(widthMeasurement).toHaveBeenCalledTimes(6); // Should only call for unique values }); }); describe('buildHeaderLineCounters', () => { const ctx = { - font: '14px sans-serif', + fontFamily: 'sans-serif', + letterSpacing: 0.15, ctx: {} as CanvasRenderingContext2D, count: jest.fn(() => 2), avgCharWidth: 7, @@ -1057,15 +963,15 @@ describe('TableNG utils', () => { describe('buildRowLineCounters', () => { const ctx = { - font: '14px sans-serif', + fontFamily: 'sans-serif', + letterSpacing: 0.15, ctx: {} as CanvasRenderingContext2D, - count: jest.fn(() => 2), wrappedCount: jest.fn(() => 2), estimateLines: jest.fn(() => 2), avgCharWidth: 7, }; - it('returns an array of line counters for each column', () => { + it('sets up text line counters for each text column if wrapping is on', () => { const fields: Field[] = [ { name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } }, { @@ -1095,6 +1001,42 @@ describe('TableNG utils', () => { expect(counters![0].fieldIdxs).toEqual([1]); }); + it('sets up line counting for pills if present and wrapping is on', () => { + const fields: Field[] = [ + { + name: 'Tags', + type: FieldType.string, + values: ['tag1,tag2', 'tag3', '["tag4","tag5","tag6"]'], + config: { custom: { cellOptions: { type: TableCellDisplayMode.Pill, wrapText: true } } }, + }, + ]; + const counters = buildRowLineCounters(fields, ctx); + expect(counters![0].estimate).toEqual(expect.any(Function)); + expect(counters![0].estimate!('tag1,tag2', 100, fields[0], 0)).toEqual(expect.any(Number)); + expect(counters![0].counter).toEqual(expect.any(Function)); + expect(counters![0].counter('tag1,tag2', 100, fields[0], 0)).toEqual(expect.any(Number)); + expect(counters![0].fieldIdxs).toEqual([0]); + }); + + it('sets up line counting for datalinks if present and wrapping is on', () => { + const fields: Field[] = [ + { + name: 'Links', + type: FieldType.string, + values: ['http://example.com/1', 'http://example.com/2'], + config: { custom: { cellOptions: { type: TableCellDisplayMode.DataLinks, wrapText: true } } }, + getLinks: jest.fn((): LinkModel[] => [ + { title: 'Link 1', href: 'http://example.com/1', target: '_blank', origin: { datasourceUid: 'test' } }, + { title: 'Link 2', href: 'http://example.com/2', target: '_self', origin: { datasourceUid: 'test' } }, + ]), + }, + ]; + const counters = buildRowLineCounters(fields, ctx); + expect(counters![0].counter).toEqual(expect.any(Function)); + expect(counters![0].counter('http://example.com/1', 100, fields[0], 0)).toEqual(expect.any(Number)); + expect(counters![0].fieldIdxs).toEqual([0]); + }); + it('does not enable text counting for non-string fields', () => { const fields: Field[] = [ { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, @@ -1162,15 +1104,15 @@ describe('TableNG utils', () => { it('should take colWidths into account when calculating max wrap cell', () => { getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10); - expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50); - expect(counters[1].counter).toHaveBeenCalledWith(123456, 60); + expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50, fields[0], 3); + expect(counters[1].counter).toHaveBeenCalledWith(123456, 60, fields[1], 3); }); // this is used to calc wrapped header height it('should use the display name if the rowIdx is -1', () => { getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10); - expect(counters[0].counter).toHaveBeenCalledWith('Name', 50); - expect(counters[1].counter).toHaveBeenCalledWith('Age', 60); + expect(counters[0].counter).toHaveBeenCalledWith('Name', 50, fields[0], -1); + expect(counters[1].counter).toHaveBeenCalledWith('Age', 60, fields[1], -1); }); it('should ignore columns which do not have line counters', () => { @@ -1230,18 +1172,8 @@ describe('TableNG utils', () => { expect( computeColWidths( [ - { - name: 'A', - type: FieldType.string, - values: [], - config: { custom: { width: 100 } }, - }, - { - name: 'B', - type: FieldType.string, - values: [], - config: { custom: { width: 200 } }, - }, + { name: 'A', type: FieldType.string, values: [], config: { custom: { width: 100 } } }, + { name: 'B', type: FieldType.string, values: [], config: { custom: { width: 200 } } }, ], 500 ) @@ -1252,18 +1184,8 @@ describe('TableNG utils', () => { expect( computeColWidths( [ - { - name: 'A', - type: FieldType.string, - values: [], - config: {}, - }, - { - name: 'B', - type: FieldType.string, - values: [], - config: { custom: { width: 200 } }, - }, + { name: 'A', type: FieldType.string, values: [], config: {} }, + { name: 'B', type: FieldType.string, values: [], config: { custom: { width: 200 } } }, ], 500 ) @@ -1274,18 +1196,8 @@ describe('TableNG utils', () => { expect( computeColWidths( [ - { - name: 'A', - type: FieldType.string, - values: [], - config: { custom: { minWidth: 100 } }, - }, - { - name: 'B', - type: FieldType.string, - values: [], - config: { custom: { minWidth: 100 } }, - }, + { name: 'A', type: FieldType.string, values: [], config: { custom: { minWidth: 100 } } }, + { name: 'B', type: FieldType.string, values: [], config: { custom: { minWidth: 100 } } }, ], 100 ) @@ -1296,18 +1208,8 @@ describe('TableNG utils', () => { expect( computeColWidths( [ - { - name: 'A', - type: FieldType.string, - values: [], - config: {}, - }, - { - name: 'B', - type: FieldType.string, - values: [], - config: {}, - }, + { name: 'A', type: FieldType.string, values: [], config: {} }, + { name: 'B', type: FieldType.string, values: [], config: {} }, ], // we have two columns but have set the table to the width of one default column. COLUMN.DEFAULT_WIDTH @@ -1332,28 +1234,14 @@ describe('TableNG utils', () => { ], }); - const sortColumns: SortColumn[] = [ - { - columnKey: 'time', - direction: 'ASC', - }, - ]; + const sortColumns: SortColumn[] = [{ columnKey: 'time', direction: 'ASC' }]; const records = applySort(frameToRecords(frame), frame.fields, sortColumns); expect(records).toMatchObject([ - { - time: 1, - value: 20, - }, - { - time: 1, - value: 10, - }, - { - time: 2, - value: 30, - }, + { time: 1, value: 20 }, + { time: 1, value: 10 }, + { time: 2, value: 30 }, ]); }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index a24a361bfda..96e6c71c018 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -25,6 +25,7 @@ import { import { getTextColorForAlphaBackground } from '../../../utils/colors'; import { TableCellOptions } from '../types'; +import { inferPills } from './Cells/PillCell'; import { COLUMN, TABLE } from './constants'; import { CellColors, @@ -92,15 +93,19 @@ export function createTypographyContext(fontSize: number, fontFamily: string, le ctx.letterSpacing = `${letterSpacing}px`; ctx.font = font; + // 1/6 of the characters in this string are capitalized. Since the avgCharWidth is used for estimation, it's + // better that the estimation over-estimates the width than if it underestimates it, so we're a little on the + // aggressive side here and could even go more aggressive if we get complaints in the future. const txt = - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s."; + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s. 1234567890 ALL CAPS TO HELP WITH MEASUREMENT."; const txtWidth = ctx.measureText(txt).width; const avgCharWidth = txtWidth / txt.length + letterSpacing; const { count } = varPreLine(ctx); return { ctx, - font, + fontFamily, + letterSpacing, avgCharWidth, estimateLines: getTextLineEstimator(avgCharWidth), wrappedCount: wrapUwrapCount(count), @@ -108,7 +113,7 @@ export function createTypographyContext(fontSize: number, fontFamily: string, le } /** - * @internal + * @internal wraps the uwrap count function to ensure that it is given a string. */ export function wrapUwrapCount(count: Count): LineCounter { return (value, width) => { @@ -141,6 +146,71 @@ export function getTextLineEstimator(avgCharWidth: number): LineCounter { }; } +/** + * @internal + */ +export function getDataLinksCounter(): LineCounter { + const linksCountCache: Record = {}; + + // when we render links, we need to filter out the invalid links. since the call to `getLinks` is expensive, + // we'll cache the result and reuse it for every row in the table. this cache is cleared when line counts are + // rebuilt anytime from the `useRowHeight` hook, and that includes adding and removing data links. + return (_value, _width, field) => { + const cacheKey = getDisplayName(field); + if (linksCountCache[cacheKey] === undefined) { + let count = 0; + for (const l of field.config?.links ?? []) { + if (l.onClick || l.url) { + count += 1; + } + } + linksCountCache[cacheKey] = count; + } + + return linksCountCache[cacheKey]; + }; +} + +const PILLS_FONT_SIZE = 12; +const PILLS_SPACING = 12; // 6px horizontal padding on each side +const PILLS_GAP = 4; // gap between pills + +export function getPillLineCounter(measureWidth: (value: string) => number): LineCounter { + const widthCache: Record = {}; + + return (value, width) => { + if (value == null) { + return 0; + } + + const pillValues = inferPills(String(value)); + if (pillValues.length === 0) { + return 0; + } + + let lines = 0; + let currentLineUse = width; + + for (const pillValue of pillValues) { + let rawWidth = widthCache[pillValue]; + if (rawWidth === undefined) { + rawWidth = measureWidth(pillValue); + widthCache[pillValue] = rawWidth; + } + const pillWidth = rawWidth + PILLS_SPACING; + + if (currentLineUse + pillWidth + PILLS_GAP > width) { + lines++; + currentLineUse = pillWidth; + } else { + currentLineUse += pillWidth + PILLS_GAP; + } + } + + return lines; + }; +} + /** * @internal return a text line counter for every field which has wrapHeaderText enabled. */ @@ -175,12 +245,33 @@ export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyC const field = fields[fieldIdx]; if (shouldTextWrap(field)) { wrappedFields++; - // TODO: Pills, DataLinks, and JSON will have custom line counters here. - // for string fields, we really want to find the longest field ahead of time to reduce the number of calls to `count`. - // calling `count` is going to get a perfectly accurate line count, but it is expensive, so we'd rather estimate the line - // count and call the counter only for the field which will take up the most space based on its - if (field.type === FieldType.string) { + const cellType = getCellOptions(field).type; + if (cellType === TableCellDisplayMode.DataLinks) { + result.dataLinksCounter = result.dataLinksCounter ?? { + counter: getDataLinksCounter(), + fieldIdxs: [], + }; + result.dataLinksCounter.fieldIdxs.push(fieldIdx); + } else if (cellType === TableCellDisplayMode.Pill) { + if (!result.pillCounter) { + const pillTypographyCtx = createTypographyContext( + PILLS_FONT_SIZE, + typographyCtx.fontFamily, + typographyCtx.letterSpacing + ); + + result.pillCounter = { + estimate: getPillLineCounter((value) => value.length * pillTypographyCtx.avgCharWidth), + counter: getPillLineCounter((value) => pillTypographyCtx.ctx.measureText(value).width), + fieldIdxs: [], + }; + } + result.pillCounter.fieldIdxs.push(fieldIdx); + } + + // for string fields, we estimate the length of a line using `avgCharWidth` to limit expensive calls `count`. + else if (field.type === FieldType.string) { result.textCounter = result.textCounter ?? { counter: typographyCtx.wrappedCount, estimate: typographyCtx.estimateLines, @@ -215,7 +306,9 @@ export function getRowHeight( defaultHeight: number, lineCounters?: LineCounterEntry[], lineHeight = TABLE.LINE_HEIGHT, - verticalPadding = 0 + // when this is a function, the field which was measured as the maximum size will be returned, as well as the + // calculated number of lines, so that the consumer can use it in case the vertical padding value differs field-by-field. + verticalPadding: number | ((field: Field, numLines: number) => number) = TABLE.CELL_PADDING ): number { if (!lineCounters?.length) { return defaultHeight; @@ -224,6 +317,7 @@ export function getRowHeight( let maxLines = -1; let maxValue = ''; let maxWidth = 0; + let maxField: Field | undefined; let preciseCounter: LineCounter | undefined; for (const { estimate, counter, fieldIdxs } of lineCounters) { @@ -239,11 +333,12 @@ export function getRowHeight( const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx]; if (cellValueRaw != null) { const colWidth = columnWidths[fieldIdx]; - const approxLines = count(cellValueRaw, colWidth); + const approxLines = count(cellValueRaw, colWidth, field, rowIdx); if (approxLines > maxLines) { maxLines = approxLines; maxValue = cellValueRaw; maxWidth = colWidth; + maxField = field; preciseCounter = isEstimating ? counter : undefined; } } @@ -252,18 +347,23 @@ export function getRowHeight( // if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust // that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter. - if (maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) { + if (maxField === undefined || maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) { return defaultHeight; } // if we finished this row height loop with an estimate, we need to call // the `preciseCounter` method to get the exact line count. if (preciseCounter !== undefined) { - maxLines = preciseCounter(maxValue, maxWidth); + maxLines = preciseCounter(maxValue, maxWidth, maxField, rowIdx); } - // we want a round number of lines for rendering - const totalHeight = Math.ceil(maxLines) * lineHeight + verticalPadding; + // round up to the nearest line before doing math + maxLines = Math.ceil(maxLines); + + // adjust for vertical padding and line height, and clamp to a minimum default height + const verticalPaddingValue = + typeof verticalPadding === 'function' ? verticalPadding(maxField, maxLines) : verticalPadding; + const totalHeight = maxLines * lineHeight + verticalPaddingValue; return Math.max(totalHeight, defaultHeight); } @@ -276,9 +376,7 @@ export function shouldTextOverflow(field: Field): boolean { const eligibleCellType = // Tech debt: Technically image cells are of type string, which is misleading (kinda?) // so we need to ensurefield.type === FieldType.string we don't apply overflow hover states for type image - (field.type === FieldType.string && - cellOptions.type !== TableCellDisplayMode.Image && - cellOptions.type !== TableCellDisplayMode.Pill) || + (field.type === FieldType.string && cellOptions.type !== TableCellDisplayMode.Image) || // regardless of the underlying cell type, data links cells have text overflow. cellOptions.type === TableCellDisplayMode.DataLinks; diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx index 34c251eee13..789b50cba2a 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipColorIndicator.tsx @@ -19,6 +19,7 @@ interface Props { colorIndicator?: ColorIndicator; position?: ColorIndicatorPosition; lineStyle?: LineStyle; + isHollow?: boolean; } export type ColorIndicatorStyles = ReturnType; @@ -28,9 +29,22 @@ export const VizTooltipColorIndicator = ({ colorIndicator = DEFAULT_COLOR_INDICATOR, position = ColorIndicatorPosition.Leading, lineStyle, + isHollow, }: Props) => { const styles = useStyles2(getStyles); + if (isHollow) { + return ( +
+ ); + } + if (colorIndicator === ColorIndicator.series) { return ( ({ trailing: css({ marginLeft: theme.spacing(0.5), }), + series: css({ + width: '14px', + height: '4px', + borderRadius: theme.shape.radius.pill, + minWidth: '14px', + }), value: css({ width: '12px', height: '12px', diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx index 40f19c37157..661581237a7 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx @@ -34,7 +34,7 @@ export const VizTooltipContent = ({ return (
- {items.map(({ label, value, color, colorIndicator, colorPlacement, isActive, lineStyle }, i) => ( + {items.map(({ label, value, color, colorIndicator, colorPlacement, isActive, lineStyle, isHiddenFromViz }, i) => ( ))} {children} diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx index 19d0bed7874..1471dc5e38a 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipRow.tsx @@ -18,6 +18,7 @@ interface VizTooltipRowProps extends Omit { marginRight?: string; isPinned: boolean; showValueScroll?: boolean; + isHiddenFromViz?: boolean; } enum LabelValueTypes { @@ -41,6 +42,7 @@ export const VizTooltipRow = ({ isPinned, lineStyle, showValueScroll, + isHiddenFromViz, }: VizTooltipRowProps) => { const styles = useStyles2(getStyles, justify, marginRight); @@ -132,7 +134,12 @@ export const VizTooltipRow = ({ {(color || label) && (
{color && colorPlacement === ColorPlacement.first && ( - + )} {!isPinned ? (
{label}
diff --git a/packages/grafana-ui/src/components/VizTooltip/types.ts b/packages/grafana-ui/src/components/VizTooltip/types.ts index de4fbae048f..bd4f66488fa 100644 --- a/packages/grafana-ui/src/components/VizTooltip/types.ts +++ b/packages/grafana-ui/src/components/VizTooltip/types.ts @@ -27,6 +27,7 @@ export interface VizTooltipItem { colorPlacement?: ColorPlacement; isActive?: boolean; lineStyle?: LineStyle; + isHiddenFromViz?: boolean; // internal/tmp for sorting numeric?: number; diff --git a/packages/grafana-ui/src/components/VizTooltip/utils.ts b/packages/grafana-ui/src/components/VizTooltip/utils.ts index e192340974f..d14d636caa5 100644 --- a/packages/grafana-ui/src/components/VizTooltip/utils.ts +++ b/packages/grafana-ui/src/components/VizTooltip/utils.ts @@ -47,6 +47,8 @@ export const calculateTooltipPosition = ( export const getColorIndicatorClass = (colorIndicator: string, styles: ColorIndicatorStyles) => { switch (colorIndicator) { + case ColorIndicator.series: + return styles.series; case ColorIndicator.value: return styles.value; case ColorIndicator.hexagon: @@ -80,7 +82,8 @@ export const getContentItems = ( mode: TooltipDisplayMode, sortOrder: SortOrder, fieldFilter = (field: Field) => true, - hideZeros = false + hideZeros = false, + _restFields?: Field[] ): VizTooltipItem[] => { let rows: VizTooltipItem[] = []; @@ -93,8 +96,7 @@ export const getContentItems = ( field === xField || field.type === FieldType.time || !fieldFilter(field) || - field.config.custom?.hideFrom?.tooltip || - field.config.custom?.hideFrom?.viz + field.config.custom?.hideFrom?.tooltip ) { continue; } @@ -130,15 +132,7 @@ export const getContentItems = ( ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER; - const colorMode = getFieldColorModeForField(field); - - let colorIndicator = ColorIndicator.series; - let colorPlacement = ColorPlacement.first; - - if (colorMode.isByValue) { - colorIndicator = ColorIndicator.value; - colorPlacement = ColorPlacement.trailing; - } + const { colorIndicator, colorPlacement } = getIndicatorAndPlacement(field); rows.push({ label: field.state?.displayName ?? field.name, @@ -152,6 +146,23 @@ export const getContentItems = ( }); } + _restFields?.forEach((field) => { + if (!field.config.custom?.hideFrom?.tooltip) { + const { colorIndicator, colorPlacement } = getIndicatorAndPlacement(field); + const display = field.display!(field.values[dataIdxs[0]!]); + + rows.push({ + label: field.state?.displayName ?? field.name, + value: formattedValueToString(display), + color: FALLBACK_COLOR, + colorIndicator, + colorPlacement, + lineStyle: field.config.custom?.lineStyle, + isHiddenFromViz: true, + }); + } + }); + if (sortOrder !== SortOrder.None && rows.length > 1) { const cmp = allNumeric ? numberCmp : stringCmp; const mult = sortOrder === SortOrder.Descending ? -1 : 1; @@ -160,3 +171,17 @@ export const getContentItems = ( return rows; }; + +const getIndicatorAndPlacement = (field: Field) => { + const colorMode = getFieldColorModeForField(field); + + let colorIndicator = ColorIndicator.series; + let colorPlacement = ColorPlacement.first; + + if (colorMode.isByValue) { + colorIndicator = ColorIndicator.value; + colorPlacement = ColorPlacement.trailing; + } + + return { colorIndicator, colorPlacement }; +}; diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index db367a8790e..d35561e598f 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -358,6 +358,7 @@ export { type UPlotConfigPrepFn } from './components/uPlot/config/UPlotConfigBui export * from './components/PanelChrome/types'; export { Label as BrowserLabel } from './components/BrowserLabel/Label'; export { PanelContainer } from './components/PanelContainer/PanelContainer'; +export { VariablesInputModal } from './components/Actions/VariablesInputModal'; // ----------------------------------------------------- // Graveyard: exported, but no longer used internally diff --git a/pkg/api/api.go b/pkg/api/api.go index 12de28eaa90..1561c28ea21 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -176,7 +176,6 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/import/dashboard", reqSignedIn, hs.Index) r.Get("/dashboards/", reqSignedIn, hs.Index) r.Get("/dashboards/*", reqSignedIn, hs.Index) - r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) if hs.Cfg.PublicDashboardsEnabled { // list public dashboards @@ -264,6 +263,9 @@ func (hs *HTTPServer) registerRoutes() { providerParam := ac.Parameter(":provider") r.Get("/admin/authentication/:provider", authorize(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsOAuth(providerParam))), hs.Index) + // ShortURL API + hs.registerShortURLAPI(r) + // authed api r.Group("/api", func(apiRoute routing.RouteRegister) { // user (signed in) @@ -549,9 +551,6 @@ func (hs *HTTPServer) registerRoutes() { // Some channels may have info liveRoute.Get("/info/*", routing.Wrap(hs.Live.HandleInfoHTTP)) }, requestmeta.SetSLOGroup(requestmeta.SLOGroupNone)) - - // short urls - apiRoute.Post("/short-urls", routing.Wrap(hs.createShortURL)) }, reqSignedIn) // admin api diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 22cb57a31f8..9be4bdab028 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -7,6 +7,8 @@ import ( "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/middleware" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/setting" @@ -14,6 +16,12 @@ import ( "github.com/grafana/grafana/pkg/web" ) +func (hs *HTTPServer) registerShortURLAPI(apiRoute routing.RouteRegister) { + reqSignedIn := middleware.ReqSignedIn + apiRoute.Post("/api/short-urls", reqSignedIn, hs.createShortURL) + apiRoute.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) +} + // createShortURL handles requests to create short URLs. func (hs *HTTPServer) createShortURL(c *contextmodel.ReqContext) response.Response { cmd := dtos.CreateShortURLCmd{} diff --git a/pkg/apimachinery/identity/static.go b/pkg/apimachinery/identity/static.go index 6c0473ae0a9..afe505e261e 100644 --- a/pkg/apimachinery/identity/static.go +++ b/pkg/apimachinery/identity/static.go @@ -106,7 +106,7 @@ func (u *StaticRequester) GetExtra() map[string][]string { } result := map[string][]string{} - if u.AccessTokenClaims.Rest.ServiceIdentity != "" { + if u.AccessTokenClaims != nil && u.AccessTokenClaims.Rest.ServiceIdentity != "" { result[authnlib.ServiceIdentityKey] = []string{u.AccessTokenClaims.Rest.ServiceIdentity} } return result diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index 24e2c11626c..4efad4e627d 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -5,7 +5,7 @@ go 1.24.5 require ( github.com/google/go-cmp v0.7.0 github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 - github.com/grafana/grafana-app-sdk/logging v0.40.0 + github.com/grafana/grafana-app-sdk/logging v0.40.1 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.22.0 github.com/stretchr/testify v1.10.0 diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 5547406bf98..dc2e70d63a1 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -84,8 +84,8 @@ github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/ github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg2svGyO40Ex92G02aOyHzP6XHCE= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= -github.com/grafana/grafana-app-sdk/logging v0.40.0 h1:3LHA0pFM9mGGFyq6qoHvJOGEe0H0OVhzzlOKd4Ehu+E= -github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= +github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index f55209ac1d6..ae7f5f93f67 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -7,6 +7,7 @@ import ( "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/datamigrations" + "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/secretsconsolidation" "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/secretsmigrations" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" @@ -184,6 +185,17 @@ var adminCommands = []*cli.Command{ }, }, }, + { + Name: "secrets-consolidation", + Usage: "Runs an operation that re-encrypts all encrypted values in your database with new data keys", + Subcommands: []*cli.Command{ + { + Name: "consolidate", + Usage: "Re-encrypts all encrypted values with new data keys and deletes the old deactivated data keys. Returns ok unless there is an error. Safe to execute multiple times.", + Action: runRunnerCommand(secretsconsolidation.ConsolidateSecrets), + }, + }, + }, } var Commands = []*cli.Command{ diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index 8e46423be87..89053e6b932 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -63,6 +63,12 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err }, } + featureManager, err := featuremgmt.ProvideManagerService(cfg) + if err != nil { + return err + } + featureToggles := featuremgmt.ProvideToggles(featureManager) + provisioning, err := newStubProvisioning(cfg.ProvisioningPath) if err != nil { return err @@ -76,9 +82,10 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err nil, // no librarypanels.Service sort.ProvideService(), acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), + featureToggles, ) - client, err := newUnifiedClient(cfg, sqlStore) + client, err := newUnifiedClient(cfg, sqlStore, featureToggles) if err != nil { return err } @@ -215,12 +222,7 @@ func promptYesNo(prompt string) (bool, error) { } } -func newUnifiedClient(cfg *setting.Cfg, sqlStore db.DB) (resource.ResourceClient, error) { - featureManager, err := featuremgmt.ProvideManagerService(cfg) - if err != nil { - return nil, err - } - featureToggles := featuremgmt.ProvideToggles(featureManager) +func newUnifiedClient(cfg *setting.Cfg, sqlStore db.DB, featureToggles featuremgmt.FeatureToggles) (resource.ResourceClient, error) { return unified.ProvideUnifiedStorageClient(&unified.Options{ Cfg: cfg, Features: featureToggles, diff --git a/pkg/cmd/grafana-cli/commands/secretsconsolidation/secretsconsolidation.go b/pkg/cmd/grafana-cli/commands/secretsconsolidation/secretsconsolidation.go new file mode 100644 index 00000000000..9be8e5f5a2a --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/secretsconsolidation/secretsconsolidation.go @@ -0,0 +1,13 @@ +package secretsconsolidation + +import ( + "context" + + "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" + "github.com/grafana/grafana/pkg/server" +) + +func ConsolidateSecrets(_ utils.CommandLine, runner server.Runner) error { + err := runner.SecretsConsolidationService.Consolidate(context.Background()) + return err +} diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index 3da3a01aab8..73d01caaade 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/search/sort" @@ -51,9 +52,10 @@ func ProvideLegacyMigrator( provisioning provisioning.ProvisioningService, // only needed for dashboard settings libraryPanelSvc librarypanels.Service, accessControl accesscontrol.AccessControl, + features featuremgmt.FeatureToggles, ) LegacyMigrator { dbp := legacysql.NewDatabaseProvider(sql) - return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), accessControl) + return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), accessControl, features) } type BlobStoreInfo struct { @@ -309,11 +311,12 @@ func (a *dashboardSqlAccess) migrateDashboards(ctx context.Context, orgId int64, for _, row := range rows.rejected { id := row.Dash.Labels[utils.LabelKeyDeprecatedInternalID] a.log.Warn("rejected dashboard", + "namespace", opts.Namespace, "dashboard", row.Dash.Name, "uid", row.Dash.UID, "id", id, + "version", row.Dash.Generation, "stackId", opts.StackID, - "namespace", opts.Namespace, ) opts.Progress(-2, fmt.Sprintf("rejected: id:%s, uid:%s", id, row.Dash.Name)) } diff --git a/pkg/registry/apis/dashboard/legacy/query_dashboards.sql b/pkg/registry/apis/dashboard/legacy/query_dashboards.sql index 75439c60d03..e8503170a2c 100644 --- a/pkg/registry/apis/dashboard/legacy/query_dashboards.sql +++ b/pkg/registry/apis/dashboard/legacy/query_dashboards.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 0771f0fc3ee..f019a39a799 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" @@ -62,6 +63,8 @@ type dashboardSqlAccess struct { namespacer request.NamespaceMapper provisioning provisioning.ProvisioningService + invalidDashboardParseFallbackEnabled bool + // Use for writing (not reading) dashStore dashboards.Store dashboardSearchClient legacysearcher.DashboardSearchClient @@ -82,17 +85,19 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, libraryPanelSvc librarypanels.Service, sorter sort.Service, accessControl accesscontrol.AccessControl, + features featuremgmt.FeatureToggles, ) DashboardAccess { dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter) return &dashboardSqlAccess{ - sql: sql, - namespacer: namespacer, - dashStore: dashStore, - provisioning: provisioning, - dashboardSearchClient: *dashboardSearchClient, - libraryPanelSvc: libraryPanelSvc, - accessControl: accessControl, - log: log.New("dashboard.legacysql"), + sql: sql, + namespacer: namespacer, + dashStore: dashStore, + provisioning: provisioning, + dashboardSearchClient: *dashboardSearchClient, + libraryPanelSvc: libraryPanelSvc, + accessControl: accessControl, + log: log.New("dashboard.legacysql"), + invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled), } } @@ -176,7 +181,7 @@ func (r *rowsWrapper) Next() bool { r.row, err = r.a.scanRow(r.rows, r.history) if err != nil { r.a.log.Error("error scanning dashboard", "error", err) - if len(r.rejected) > 0 || r.row == nil { + if len(r.rejected) > 100 || r.row == nil { r.err = fmt.Errorf("too many rejected rows (%d) %w", len(r.rejected), err) return false } @@ -228,6 +233,51 @@ func (r *rowsWrapper) Value() []byte { return b } +func generateFallbackDashboard(data []byte, title, uid string) ([]byte, error) { + generatedDashboard := map[string]interface{}{ + "editable": true, + "id": 1, + "panels": []map[string]interface{}{ + { + "description": "The JSON is invalid. You can import it again after fixing it.", + "gridPos": map[string]interface{}{"h": 8, "w": 24, "x": 0, "y": 0}, + "id": 1, + "options": map[string]interface{}{ + "code": map[string]interface{}{"language": "plaintext", "showLineNumbers": false, "showMiniMap": false}, + "content": string(data), + "mode": "code", + }, + "title": "Invalid dashboard", + "type": "text", + }, + }, + "schemaVersion": 41, + "title": title, + "uid": uid, + "version": 3, + } + return json.Marshal(generatedDashboard) +} + +func (a *dashboardSqlAccess) parseDashboard(dash *dashboardV1.Dashboard, data []byte, id int64, title string) error { + if err := dash.Spec.UnmarshalJSON(data); err != nil { + a.log.Warn("error unmarshalling dashboard spec. Generating fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name) + dash.Spec = *dashboardV0.NewDashboardSpec() + + dashboardData, err := generateFallbackDashboard(data, title, string(dash.UID)) + if err != nil { + a.log.Warn("error generating fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name) + return err + } + + if err = dash.Spec.UnmarshalJSON(dashboardData); err != nil { + a.log.Warn("error unmarshalling fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name) + return err + } + } + return nil +} + func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRow, error) { dash := &dashboardV1.Dashboard{ TypeMeta: dashboardV1.DashboardResourceInfo.TypeMeta(), @@ -238,6 +288,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo var dashboard_id int64 var orgId int64 var folder_uid sql.NullString + var title string var updated time.Time var updatedBy sql.NullString var updatedByID sql.NullInt64 @@ -257,7 +308,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo var data []byte // the dashboard JSON var version int64 - err := rows.Scan(&orgId, &dashboard_id, &dash.Name, &folder_uid, + err := rows.Scan(&orgId, &dashboard_id, &dash.Name, &title, &folder_uid, &deleted, &plugin_id, &origin_name, &origin_path, &origin_hash, &origin_ts, &created, &createdBy, &createdByID, @@ -286,6 +337,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo dash.SetCreationTimestamp(metav1.NewTime(created)) meta, err := utils.MetaAccessor(dash) if err != nil { + a.log.Debug("failed to get meta accessor for dashboard", "error", err, "uid", dash.UID, "name", dash.Name, "version", version) return nil, err } meta.SetUpdatedTimestamp(&updated) @@ -331,9 +383,14 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo } if len(data) > 0 { - err = dash.Spec.UnmarshalJSON(data) - if err != nil { - return row, fmt.Errorf("JSON unmarshal error for: %s // %w", dash.Name, err) + if a.invalidDashboardParseFallbackEnabled { + if err := a.parseDashboard(dash, data, dashboard_id, title); err != nil { + return row, err + } + } else { + if err := dash.Spec.UnmarshalJSON(data); err != nil { + return row, fmt.Errorf("JSON unmarshal error for: %s // %w", dash.Name, err) + } } } // Ignore any saved values for id/version/uid diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go index 41da615b6ff..da8d2bfe25d 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -32,13 +32,15 @@ func TestScanRow(t *testing.T) { provisioner := provisioning.NewProvisioningServiceMock(context.Background()) provisioner.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "provisioner" } store := &dashboardSqlAccess{ - namespacer: func(_ int64) string { return "default" }, - provisioning: provisioner, - log: log.New("test"), + namespacer: func(_ int64) string { return "default" }, + provisioning: provisioner, + log: log.New("test"), + invalidDashboardParseFallbackEnabled: false, } - columns := []string{"orgId", "dashboard_id", "name", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data", "api_version"} + columns := []string{"orgId", "dashboard_id", "name", "title", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data", "api_version"} id := int64(100) + uid := "someuid" title := "Test Dashboard" folderUID := "folder123" timestamp := time.Now() @@ -49,7 +51,7 @@ func TestScanRow(t *testing.T) { updatedUser := "updator" t.Run("Should scan a valid row correctly", func(t *testing.T) { - rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`), "vXyz") + rows := sqlmock.NewRows(columns).AddRow(1, id, uid, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`), "vXyz") mock.ExpectQuery("SELECT *").WillReturnRows(rows) resultRows, err := mockDB.Query("SELECT *") require.NoError(t, err) @@ -59,7 +61,7 @@ func TestScanRow(t *testing.T) { row, err := store.scanRow(resultRows, false) require.NoError(t, err) require.NotNil(t, row) - require.Equal(t, "Test Dashboard", row.Dash.Name) + require.Equal(t, uid, row.Dash.Name) require.Equal(t, version, row.RV) // rv should be the dashboard version require.Equal(t, common.Unstructured{ Object: map[string]interface{}{"key": "value"}, @@ -80,7 +82,7 @@ func TestScanRow(t *testing.T) { }) t.Run("File provisioned dashboard should have annotations", func(t *testing.T) { - rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "provisioner", pathToFile, "hashing", 100000, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`), "vXyz") + rows := sqlmock.NewRows(columns).AddRow(1, id, uid, title, folderUID, nil, "", "provisioner", pathToFile, "hashing", 100000, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`), "vXyz") mock.ExpectQuery("SELECT *").WillReturnRows(rows) resultRows, err := mockDB.Query("SELECT *") require.NoError(t, err) @@ -108,7 +110,7 @@ func TestScanRow(t *testing.T) { }) t.Run("Plugin provisioned dashboard should have annotations", func(t *testing.T) { - rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "slo", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`), "vXyz") + rows := sqlmock.NewRows(columns).AddRow(1, id, uid, title, folderUID, nil, "slo", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`), "vXyz") mock.ExpectQuery("SELECT *").WillReturnRows(rows) resultRows, err := mockDB.Query("SELECT *") require.NoError(t, err) @@ -144,7 +146,7 @@ func TestScanRow(t *testing.T) { // In migration scenario, COALESCE functions return dashboard table values // when dashboard_version values are NULL, ensuring all dashboards are migrated rows := sqlmock.NewRows(columns).AddRow( - 1, id, title, folderUID, nil, "", // basic dashboard fields + 1, id, uid, title, folderUID, nil, "", // basic dashboard fields "", "", "", 0, // origin fields timestamp, createdUser, 0, // created fields // These represent COALESCED values from dashboard table (not version table) @@ -163,7 +165,8 @@ func TestScanRow(t *testing.T) { require.NotNil(t, row) // Verify migration scenario works correctly with fallback data - require.Equal(t, title, row.Dash.Name) + require.Equal(t, uid, row.Dash.Name) + require.Equal(t, "Migrated Dashboard", row.Dash.Spec.Object["title"]) require.Equal(t, migrationVersion, row.RV) // Should use COALESCEd dashboard table version require.Equal(t, common.Unstructured{ Object: map[string]interface{}{ @@ -187,6 +190,72 @@ func TestScanRow(t *testing.T) { require.Equal(t, folderUID, meta.GetFolder()) require.Equal(t, "dashboard.grafana.app/"+migrationAPIVersion, row.Dash.APIVersion) }) + + t.Run("should follow dashboard template when failing to unmarshal dashboard if feature flag X is enabled", func(t *testing.T) { + // row with bad data + badData := []byte(`{"rows":[{"panels":[{"targets":[{"refId":"A","target":"aliasSub(alias, '^(.{27}).+', '\1...')"}]}]}]}`) + rows := sqlmock.NewRows(columns).AddRow(1, id, uid, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, badData, "vXyz") + mock.ExpectQuery("SELECT *").WillReturnRows(rows) + resultRows, err := mockDB.Query("SELECT *") + require.NoError(t, err) + defer resultRows.Close() // nolint:errcheck + resultRows.Next() + + row, err := store.scanRow(resultRows, false) + require.Error(t, err, "JSON unmarshal error for: Test Dashboard // invalid character '1' in string escape code") + require.NotNil(t, row) + // correctly scans these + require.Equal(t, uid, row.Dash.Name) + require.Equal(t, version, row.RV) + require.Equal(t, "default", row.Dash.Namespace) + require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token) + + // failure case: does NOT parse the dashboard itself + require.Equal(t, common.Unstructured{ + Object: nil, + }, row.Dash.Spec) + + // store with feature flag enabled + store = &dashboardSqlAccess{ + namespacer: func(_ int64) string { return "default" }, + provisioning: provisioner, + log: log.New("test"), + invalidDashboardParseFallbackEnabled: true, + } + + row, err = store.scanRow(resultRows, false) + require.NoError(t, err) + require.NotNil(t, row) + require.Equal(t, uid, row.Dash.Name) + require.Equal(t, version, row.RV) + require.Equal(t, "default", row.Dash.Namespace) + require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token) + + // instead of failing, create dummy dashboard with broken json inlined in text panel + require.Equal(t, title, row.Dash.Spec.Object["title"]) + panels, exists := row.Dash.Spec.Object["panels"] + require.True(t, exists, "panels property should exist") + + panelsSlice, ok := panels.([]interface{}) + require.True(t, ok, "panels should be a slice") + require.Len(t, panelsSlice, 1, "panels should have exactly one element") + + panel, ok := panelsSlice[0].(map[string]interface{}) + require.True(t, ok, "panel should be a map") + + options, exists := panel["options"] + require.True(t, exists, "panel should have options property") + + optionsMap, ok := options.(map[string]interface{}) + require.True(t, ok, "options should be a map") + + content, exists := optionsMap["content"] + require.True(t, exists, "options should have content property") + + contentStr, ok := content.(string) + require.True(t, ok, "content should be a string") + require.Equal(t, string(badData), contentStr, "content should match bad json data") + }) } func TestBuildSaveDashboardCommand(t *testing.T) { diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard.sql index e3a945a53db..63f1231205e 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard_next_page.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard_next_page.sql index 862725168b0..0992bbd3ce2 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard_next_page.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-dashboard_next_page.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-export_with_history.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-export_with_history.sql index fd54e6dc949..1d57a86d450 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-export_with_history.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-export_with_history.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-folders.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-folders.sql index 5935eb3422c..38cf8f56bea 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-folders.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-folders.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid.sql index ec0a40a5934..5c1a6974590 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql index e04b1ff7430..bf85c6ba3d5 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_at_version.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_second_page.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_second_page.sql index ec0a40a5934..5c1a6974590 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_second_page.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-history_uid_second_page.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-migration_with_fallback.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-migration_with_fallback.sql index ba38c048136..8c3801ba771 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-migration_with_fallback.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_dashboards-migration_with_fallback.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard.sql index 552a486f8cd..c04123f90ab 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard_next_page.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard_next_page.sql index fb09d57d08d..5916a9b315f 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard_next_page.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-dashboard_next_page.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-export_with_history.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-export_with_history.sql index 4d0affb337f..5eec54770c7 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-export_with_history.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-export_with_history.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-folders.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-folders.sql index b994d617708..5c9cca1e30e 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-folders.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-folders.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid.sql index 9bcbb168149..876fca02fe3 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql index 61311c5c99b..0fa23f2db08 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_at_version.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_second_page.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_second_page.sql index 9bcbb168149..876fca02fe3 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_second_page.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-history_uid_second_page.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-migration_with_fallback.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-migration_with_fallback.sql index 364fbebf417..fee2c28d525 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-migration_with_fallback.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_dashboards-migration_with_fallback.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard.sql index 552a486f8cd..c04123f90ab 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard_next_page.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard_next_page.sql index fb09d57d08d..5916a9b315f 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard_next_page.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-dashboard_next_page.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-export_with_history.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-export_with_history.sql index 4d0affb337f..5eec54770c7 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-export_with_history.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-export_with_history.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-folders.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-folders.sql index b994d617708..5c9cca1e30e 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-folders.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-folders.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid.sql index 9bcbb168149..876fca02fe3 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql index 61311c5c99b..0fa23f2db08 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_at_version.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_second_page.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_second_page.sql index 9bcbb168149..876fca02fe3 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_second_page.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-history_uid_second_page.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-migration_with_fallback.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-migration_with_fallback.sql index 364fbebf417..fee2c28d525 100755 --- a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-migration_with_fallback.sql +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_dashboards-migration_with_fallback.sql @@ -2,6 +2,7 @@ SELECT dashboard.org_id, dashboard.id, dashboard.uid, + dashboard.title, dashboard.folder_uid, dashboard.deleted, plugin_id, diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 19108edadf8..75e2ed5b518 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -143,7 +143,7 @@ func RegisterAPIService( folderClient: folderClient, legacy: &DashboardStorage{ - Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, accessControl), + Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, accessControl, features), DashboardService: dashboardService, }, reg: reg, diff --git a/pkg/registry/apis/provisioning/jobs/export/folders.go b/pkg/registry/apis/provisioning/jobs/export/folders.go index afc7db05a87..c8b108d6023 100644 --- a/pkg/registry/apis/provisioning/jobs/export/folders.go +++ b/pkg/registry/apis/provisioning/jobs/export/folders.go @@ -32,8 +32,9 @@ func ExportFolders(ctx context.Context, repoName string, options provisioning.Ex } manager, _ := meta.GetManagerProperties() - if manager.Identity == repoName { - return nil // skip it... already in tree? + // Skip if already managed by any manager (repository, file provisioning, etc.) + if manager.Identity != "" { + return nil } return tree.AddUnstructured(item) diff --git a/pkg/registry/apis/provisioning/jobs/export/folders_test.go b/pkg/registry/apis/provisioning/jobs/export/folders_test.go index bec0214d7ef..49c07a64347 100644 --- a/pkg/registry/apis/provisioning/jobs/export/folders_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/folders_test.go @@ -298,38 +298,21 @@ func TestExportFolders(t *testing.T) { progress.On("SetMessage", mock.Anything, "read folder tree from API server").Return() progress.On("SetMessage", mock.Anything, "write folders to repository").Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "parent-uid" && result.Action == repository.FileActionCreated + return result.Name == "parent-folder" && result.Action == repository.FileActionCreated })).Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "child-uid" && result.Action == repository.FileActionCreated + return result.Name == "child-folder" && result.Action == repository.FileActionCreated })).Return() progress.On("TooManyErrors").Return(nil) progress.On("TooManyErrors").Return(nil) }, setupResources: func(repoResources *resources.MockRepositoryResources) { repoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool { - expectedFolders := []resources.Folder{ - {ID: "parent-folder", Path: "parent-folder"}, - {ID: "child-folder", Path: "parent-folder/child-folder"}, - } - - if tree.Count() != len(expectedFolders) { - return false - } - - for _, folder := range expectedFolders { - dir, ok := tree.DirPath(folder.ID, "") - if !ok || dir.Path != folder.Path { - return false - } - } - - return true + return tree.Count() == 2 }), mock.MatchedBy(func(fn func(folder resources.Folder, created bool, err error) error) bool { - // Parent folder should be processed first - require.NoError(t, fn(resources.Folder{ID: "parent-uid", Path: "grafana/parent-folder"}, true, nil)) - // Then child folder with nested path - require.NoError(t, fn(resources.Folder{ID: "child-uid", Path: "grafana/parent-folder/child-folder"}, true, nil)) + require.NoError(t, fn(resources.Folder{ID: "parent-folder", Path: "grafana/parent-folder"}, true, nil)) + require.NoError(t, fn(resources.Folder{ID: "child-folder", Path: "grafana/parent-folder/child-folder"}, true, nil)) + return true })).Return(nil) }, @@ -380,7 +363,7 @@ func TestExportFolders(t *testing.T) { } func TestFolderMetaAccessor(t *testing.T) { - t.Run("should export folders from another manager", func(t *testing.T) { + t.Run("should skip folders from another manager", func(t *testing.T) { obj := &unstructured.Unstructured{ Object: map[string]interface{}{ "metadata": map[string]interface{}{ @@ -405,21 +388,12 @@ func TestFolderMetaAccessor(t *testing.T) { mockRepoResources := resources.NewMockRepositoryResources(t) mockRepoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool { - return tree.Count() == 1 - }), mock.MatchedBy(func(fn func(folder resources.Folder, created bool, err error) error) bool { - require.NoError(t, fn(resources.Folder{ID: "test-folder-uid", Path: "grafana/test-folder"}, true, nil)) - return true - })).Return(nil) + return tree.Count() == 0 // Should be 0 since folder is managed by other manager + }), mock.Anything).Return(nil) progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Name == "test-folder-uid" && - result.Error == nil && - result.Path == "grafana/test-folder" - })).Return() - progress.On("TooManyErrors").Return(nil) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Twice() + // No Record calls expected since folder should be skipped err = ExportFolders(context.Background(), "test-repo", v0alpha1.ExportJobOptions{ Path: "grafana", Branch: "feature/branch", @@ -430,7 +404,7 @@ func TestFolderMetaAccessor(t *testing.T) { mockRepoResources.AssertExpectations(t) progress.AssertExpectations(t) }) - t.Run("should skip if repo is the manager", func(t *testing.T) { + t.Run("should skip if current repo is the manager", func(t *testing.T) { obj := &unstructured.Unstructured{ Object: map[string]interface{}{ "metadata": map[string]interface{}{ @@ -492,6 +466,45 @@ func TestFolderMetaAccessor(t *testing.T) { mockRepoResources.AssertExpectations(t) progress.AssertExpectations(t) }) + t.Run("should skip if managed by any other manager", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-folder", + "annotations": map[string]interface{}{ + "folder.grafana.app/uid": "test-folder-uid", + }, + }, + }, + } + meta, err := utils.MetaAccessor(obj) + require.NoError(t, err) + meta.SetManagerProperties(utils.ManagerProperties{ + Kind: utils.ManagerKindTerraform, + Identity: "terraform-provisioning", + AllowsEdits: false, + Suspended: false, + }) + fakeFolderClient := &mockDynamicInterface{ + items: []unstructured.Unstructured{*obj}, + } + + mockRepoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Twice() + mockRepoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool { + return tree.Count() == 0 // Should be empty since folder was skipped + }), mock.Anything).Return(nil) + + err = ExportFolders(context.Background(), "test-repo", v0alpha1.ExportJobOptions{ + Path: "grafana", + Branch: "feature/branch", + }, fakeFolderClient, mockRepoResources, progress) + + require.NoError(t, err) + mockRepoResources.AssertExpectations(t) + progress.AssertExpectations(t) + }) } // mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index b986392c3d6..54805784cee 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/dynamic" + "github.com/grafana/grafana/pkg/apimachinery/utils" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" @@ -103,6 +104,23 @@ func exportResource(ctx context.Context, Action: repository.FileActionCreated, } + // Check if resource is already managed by a repository + meta, err := utils.MetaAccessor(item) + if err != nil { + result.Action = repository.FileActionIgnored + result.Error = fmt.Errorf("extract meta accessor: %w", err) + progress.Record(ctx, result) + return nil + } + + manager, _ := meta.GetManagerProperties() + // Skip if already managed by any manager (repository, file provisioning, etc.) + if manager.Identity != "" { + result.Action = repository.FileActionIgnored + progress.Record(ctx, result) + return nil + } + if shim != nil { item, err = shim(ctx, item) } diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_test.go index db8209cf414..e77ae6105ed 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/grafana/grafana/pkg/apimachinery/utils" mock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -532,3 +533,37 @@ func TestExportResources_Dashboards_V2beta1_ClientError(t *testing.T) { err := runExportTest(t, mockItems, setupProgress, setupResources) require.NoError(t, err) } + +func TestExportResources_Dashboards_SkipsManagedResources(t *testing.T) { + // Create a dashboard managed by file provisioning + dashboard := createDashboardObject("managed-dashboard") + + // Add manager metadata using utils package + meta, err := utils.MetaAccessor(&dashboard) + require.NoError(t, err) + meta.SetManagerProperties(utils.ManagerProperties{ + Kind: utils.ManagerKindTerraform, + Identity: "terraform-provisioning", + AllowsEdits: false, + Suspended: false, + }) + + mockItems := []unstructured.Unstructured{dashboard} + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "managed-dashboard" && result.Action == repository.FileActionIgnored + })).Return() + progress.On("TooManyErrors").Return(nil).Maybe() + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + // No WriteResourceFileFromObject call expected since resource should be skipped + } + + err = runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} diff --git a/pkg/registry/apis/provisioning/repository/git/repository.go b/pkg/registry/apis/provisioning/repository/git/repository.go index 5bcdb12cd94..7ca9ed8998e 100644 --- a/pkg/registry/apis/provisioning/repository/git/repository.go +++ b/pkg/registry/apis/provisioning/repository/git/repository.go @@ -790,6 +790,10 @@ func (r *gitRepository) createSignature(ctx context.Context) (nanogit.Author, na func (r *gitRepository) commit(ctx context.Context, writer nanogit.StagedWriter, comment string) error { author, committer := r.createSignature(ctx) if _, err := writer.Commit(ctx, comment, author, committer); err != nil { + if errors.Is(err, nanogit.ErrNothingToCommit) { + return repository.ErrNothingToCommit + } + return fmt.Errorf("commit changes: %w", err) } return nil diff --git a/pkg/registry/apis/provisioning/repository/git/staged.go b/pkg/registry/apis/provisioning/repository/git/staged.go index a3f79567029..f55eb709341 100644 --- a/pkg/registry/apis/provisioning/repository/git/staged.go +++ b/pkg/registry/apis/provisioning/repository/git/staged.go @@ -219,12 +219,24 @@ func (r *stagedGitRepository) Push(ctx context.Context) error { if message == "" { message = "Staged changes" } + if err := r.commit(ctx, r.writer, message); err != nil { return err } } - return r.writer.Push(ctx) + err := r.writer.Push(ctx) + if err != nil { + // Convert nanogit-specific errors to repository-level errors to avoid leaky abstraction + if errors.Is(err, nanogit.ErrNothingToPush) { + return repository.ErrNothingToPush + } + if errors.Is(err, nanogit.ErrNothingToCommit) { + return repository.ErrNothingToCommit + } + return err + } + return nil } func (r *stagedGitRepository) Remove(ctx context.Context) error { diff --git a/pkg/registry/apis/provisioning/repository/git/staged_test.go b/pkg/registry/apis/provisioning/repository/git/staged_test.go index 4501a087e7a..77c1d8b5edb 100644 --- a/pkg/registry/apis/provisioning/repository/git/staged_test.go +++ b/pkg/registry/apis/provisioning/repository/git/staged_test.go @@ -3,6 +3,7 @@ package git import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -995,6 +996,54 @@ func TestStagedGitRepository_Push(t *testing.T) { expectPushCalls: 1, expectCommitCalls: 1, }, + { + name: "returns repository ErrNothingToPush when nanogit returns ErrNothingToPush", + opts: repository.StageOptions{}, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.PushReturns(nanogit.ErrNothingToPush) + }, + wantError: repository.ErrNothingToPush, + expectPushCalls: 1, + expectCommitCalls: 0, + }, + { + name: "returns repository ErrNothingToCommit when nanogit returns ErrNothingToCommit", + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnlyOnce, + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(nil, nanogit.ErrNothingToCommit) + }, + wantError: repository.ErrNothingToCommit, + expectPushCalls: 0, + expectCommitCalls: 1, + }, + { + name: "returns repository ErrNothingToPush when nanogit returns wrapped ErrNothingToPush", + opts: repository.StageOptions{}, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // Use fmt.Errorf with %w to create a wrapped error that errors.Is can detect + wrappedErr := fmt.Errorf("git operation failed: %w", nanogit.ErrNothingToPush) + mockWriter.PushReturns(wrappedErr) + }, + wantError: repository.ErrNothingToPush, + expectPushCalls: 1, + expectCommitCalls: 0, + }, + { + name: "returns repository ErrNothingToCommit when nanogit returns wrapped ErrNothingToCommit", + opts: repository.StageOptions{ + Mode: repository.StageModeCommitOnlyOnce, + }, + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // Use fmt.Errorf with %w to create a wrapped error that errors.Is can detect + wrappedErr := fmt.Errorf("git operation failed: %w", nanogit.ErrNothingToCommit) + mockWriter.CommitReturns(nil, wrappedErr) + }, + wantError: repository.ErrNothingToCommit, + expectPushCalls: 0, + expectCommitCalls: 1, + }, } for _, tt := range tests { @@ -1007,7 +1056,12 @@ func TestStagedGitRepository_Push(t *testing.T) { err := stagedRepo.Push(context.Background()) if tt.wantError != nil { - require.EqualError(t, err, tt.wantError.Error()) + // For nanogit error conversion tests, use ErrorIs to verify type conversion + if errors.Is(tt.wantError, repository.ErrNothingToPush) || errors.Is(tt.wantError, repository.ErrNothingToCommit) { + require.ErrorIs(t, err, tt.wantError) + } else { + require.EqualError(t, err, tt.wantError.Error()) + } } else { require.NoError(t, err) } diff --git a/pkg/registry/apis/provisioning/repository/staged.go b/pkg/registry/apis/provisioning/repository/staged.go index ca5b59c72c9..1861d66336c 100644 --- a/pkg/registry/apis/provisioning/repository/staged.go +++ b/pkg/registry/apis/provisioning/repository/staged.go @@ -7,9 +7,14 @@ import ( "time" "github.com/grafana/grafana-app-sdk/logging" - "github.com/grafana/nanogit" ) +// ErrNothingToPush indicates that there are no changes to push to the remote repository +var ErrNothingToPush = errors.New("nothing to push") + +// ErrNothingToCommit indicates that there are no changes to commit +var ErrNothingToCommit = errors.New("nothing to commit") + //go:generate mockery --name WrapWithStageFn --structname MockWrapWithStageFn --inpackage --filename mock_wrap_with_stage_fn.go --with-expecter type WrapWithStageFn func(ctx context.Context, repo Repository, stageOptions StageOptions, fn func(repo Repository, staged bool) error) error @@ -83,7 +88,7 @@ func WrapWithStageAndPushIfPossible( } if err = staged.Push(ctx); err != nil { - if errors.Is(err, nanogit.ErrNothingToPush) { + if errors.Is(err, ErrNothingToPush) || errors.Is(err, ErrNothingToCommit) { return nil // OK, already pushed } return fmt.Errorf("wrapped push error: %w", err) diff --git a/pkg/registry/apis/provisioning/repository/staged_test.go b/pkg/registry/apis/provisioning/repository/staged_test.go index 3efe8ebbd5e..c640e2cd0db 100644 --- a/pkg/registry/apis/provisioning/repository/staged_test.go +++ b/pkg/registry/apis/provisioning/repository/staged_test.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "fmt" "testing" "github.com/stretchr/testify/mock" @@ -127,6 +128,84 @@ func TestWrapWithStageAndPushIfPossible(t *testing.T) { return nil }, }, + { + name: "nothing to push - should not error", + setupMocks: func(t *testing.T) *mockStagedRepo { + mockRepo := NewMockStageableRepository(t) + mockStaged := NewMockStagedRepository(t) + + mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil) + mockStaged.EXPECT().Push(mock.Anything).Return(ErrNothingToPush) + mockStaged.EXPECT().Remove(mock.Anything).Return(nil) + + return &mockStagedRepo{ + MockStageableRepository: mockRepo, + MockStagedRepository: mockStaged, + } + }, + operation: func(repo Repository, staged bool) error { + return nil + }, + }, + { + name: "nothing to commit - should not error", + setupMocks: func(t *testing.T) *mockStagedRepo { + mockRepo := NewMockStageableRepository(t) + mockStaged := NewMockStagedRepository(t) + + mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil) + mockStaged.EXPECT().Push(mock.Anything).Return(ErrNothingToCommit) + mockStaged.EXPECT().Remove(mock.Anything).Return(nil) + + return &mockStagedRepo{ + MockStageableRepository: mockRepo, + MockStagedRepository: mockStaged, + } + }, + operation: func(repo Repository, staged bool) error { + return nil + }, + }, + { + name: "wrapped nothing to push error - should not error", + setupMocks: func(t *testing.T) *mockStagedRepo { + mockRepo := NewMockStageableRepository(t) + mockStaged := NewMockStagedRepository(t) + + wrappedErr := fmt.Errorf("some wrapper: %w", ErrNothingToPush) + mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil) + mockStaged.EXPECT().Push(mock.Anything).Return(wrappedErr) + mockStaged.EXPECT().Remove(mock.Anything).Return(nil) + + return &mockStagedRepo{ + MockStageableRepository: mockRepo, + MockStagedRepository: mockStaged, + } + }, + operation: func(repo Repository, staged bool) error { + return nil + }, + }, + { + name: "wrapped nothing to commit error - should not error", + setupMocks: func(t *testing.T) *mockStagedRepo { + mockRepo := NewMockStageableRepository(t) + mockStaged := NewMockStagedRepository(t) + + wrappedErr := fmt.Errorf("some wrapper: %w", ErrNothingToCommit) + mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil) + mockStaged.EXPECT().Push(mock.Anything).Return(wrappedErr) + mockStaged.EXPECT().Remove(mock.Anything).Return(nil) + + return &mockStagedRepo{ + MockStageableRepository: mockRepo, + MockStagedRepository: mockStaged, + } + }, + operation: func(repo Repository, staged bool) error { + return nil + }, + }, } for _, tt := range tests { diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go index 28dd06ea43a..ec5942c21ec 100644 --- a/pkg/registry/apis/provisioning/resources/resources.go +++ b/pkg/registry/apis/provisioning/resources/resources.go @@ -92,19 +92,29 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj if title == "" { title = name } - folder := meta.GetFolder() + folder := meta.GetFolder() // Get the absolute path of the folder rootFolder := RootFolder(r.repo.Config()) - fid, ok := r.folders.Tree().DirPath(folder, rootFolder) - if !ok { - return "", fmt.Errorf("folder not found in tree: %s", folder) + + // If no folder is specified in the file, set it to the root to ensure everything is written under it + var fid Folder + if folder == "" { + fid = Folder{ID: rootFolder} + meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder + } else { + var ok bool + fid, ok = r.folders.Tree().DirPath(folder, rootFolder) + if !ok { + return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder) + } } fileName := slugify.Slugify(title) + ".json" if fid.Path != "" { fileName = safepath.Join(fid.Path, fileName) } + if options.Path != "" { fileName = safepath.Join(options.Path, fileName) } diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index 66a1db38eae..a70aa657765 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -3,9 +3,11 @@ package query import ( "context" "encoding/json" + "errors" "net/http" "slices" "strconv" + "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" @@ -108,6 +110,7 @@ func (r *queryREST) NewConnectOptions() (runtime.Object, bool, string) { return nil, false, "" // true means you can use the trailing path as a variable } +// called by mt query service and also when queryServiceFromUI is enabled, can be both mt and st func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.Object, incomingResponder rest.Responder) (http.Handler, error) { // See: /pkg/services/apiserver/builder/helper.go#L34 // The name is set with a rewriter hack @@ -175,7 +178,7 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O return } - qdr, err := handleQuery(ctx, *raw, *b, httpreq, *responder) + qdr, err := handleQuery(ctx, *raw, *b, httpreq, *responder, connectLogger) if err != nil { b.log.Error("execute error", "http code", query.GetResponseCode(qdr), "err", err) @@ -186,9 +189,25 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O }) return } else { - // return the error to the client, will send all non k8s errors as a k8 unexpected error - b.log.Error("hit unexpected error while executing query, this will show as an unhandled k8s status error", "err", err) - responder.Error(err) + var errorDataResponse backend.DataResponse + if errors.Is(err, service.ErrInvalidDatasourceID) || errors.Is(err, service.ErrNoQueriesFound) || errors.Is(err, service.ErrMissingDataSourceInfo) || errors.Is(err, service.ErrQueryParamMismatch) || errors.Is(err, service.ErrDuplicateRefId) { + errorDataResponse = backend.ErrDataResponseWithSource(backend.StatusBadRequest, backend.ErrorSourceDownstream, err.Error()) + } else if strings.Contains(err.Error(), "expression request error") { + b.log.Error("Error calling TransformData in an expression", "err", err) + errorDataResponse = backend.ErrDataResponseWithSource(backend.StatusBadRequest, backend.ErrorSourceDownstream, err.Error()) + } else { + b.log.Error("unknown error, treated as a 500", "err", err) + responder.Error(err) + return + } + qdr = &backend.QueryDataResponse{ + Responses: map[string]backend.DataResponse{ + "A": errorDataResponse, + }, + } + responder.Object(query.GetResponseCode(qdr), &query.QueryDataResponse{ + QueryDataResponse: *qdr, + }) return } } @@ -199,7 +218,7 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O }), nil } -func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuilder, httpreq *http.Request, responder responderWrapper) (*backend.QueryDataResponse, error) { +func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuilder, httpreq *http.Request, responder responderWrapper, connectLogger log.Logger) (*backend.QueryDataResponse, error) { var jsonQueries = make([]*simplejson.Json, 0, len(raw.Queries)) for _, query := range raw.Queries { jsonBytes, err := json.Marshal(query) @@ -214,6 +233,7 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil jsonQueries = append(jsonQueries, sjQuery) } + mReq := dtos.MetricRequest{ From: raw.From, To: raw.To, @@ -228,17 +248,19 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil instanceConfig, err := b.clientSupplier.GetInstanceConfigurationSettings(ctx) if err != nil { - b.log.Error("failed to get instance configuration settings", "err", err) + connectLogger.Error("failed to get instance configuration settings", "err", err) responder.Error(err) return nil, err } + dsQuerierLoggerWithSlug := connectLogger.New("slug", instanceConfig.Options["slug"], "ruleuid", headers["X-Rule-Uid"]) + mtDsClientBuilder := mtdsclient.NewMtDatasourceClientBuilderWithClientSupplier( b.clientSupplier, ctx, headers, instanceConfig, - b.log, + dsQuerierLoggerWithSlug, ) exprService := expr.ProvideService( @@ -256,7 +278,7 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil mtDsClientBuilder, ) - qdr, err := service.QueryData(ctx, b.log, cache, exprService, mReq, mtDsClientBuilder, headers) + qdr, err := service.QueryData(ctx, dsQuerierLoggerWithSlug, cache, exprService, mReq, mtDsClientBuilder, headers) if err != nil { return qdr, err diff --git a/pkg/registry/apis/secret/contracts/decrypt.go b/pkg/registry/apis/secret/contracts/decrypt.go index 32f9f7f6c1d..2c5403c790d 100644 --- a/pkg/registry/apis/secret/contracts/decrypt.go +++ b/pkg/registry/apis/secret/contracts/decrypt.go @@ -27,7 +27,7 @@ type DecryptAuthorizer interface { Authorize(ctx context.Context, secureValueName string, secureValueDecrypters []string) (identity string, allowed bool) } -// DecryptService is the inferface for the decrypt service. +// DecryptService is the interface for the decrypt service. type DecryptService interface { Decrypt(ctx context.Context, namespace string, names ...string) (map[string]DecryptResult, error) Close() error diff --git a/pkg/registry/apis/secret/contracts/encryption.go b/pkg/registry/apis/secret/contracts/encryption.go index f0596de9034..f24fe73a544 100644 --- a/pkg/registry/apis/secret/contracts/encryption.go +++ b/pkg/registry/apis/secret/contracts/encryption.go @@ -38,3 +38,7 @@ type GlobalEncryptedValueStorage interface { ListAll(ctx context.Context, opts ListOpts, untilTime *int64) ([]*EncryptedValue, error) CountAll(ctx context.Context, untilTime *int64) (int64, error) } + +type ConsolidationService interface { + Consolidate(ctx context.Context) error +} diff --git a/pkg/registry/apis/secret/contracts/inline.go b/pkg/registry/apis/secret/contracts/inline.go new file mode 100644 index 00000000000..f2f3de71568 --- /dev/null +++ b/pkg/registry/apis/secret/contracts/inline.go @@ -0,0 +1,19 @@ +package contracts + +import ( + "context" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +type InlineSecureValueSupport interface { + // Check that the request user can reference secure value names in the context of a given resource (owner) + CanReference(ctx context.Context, owner common.ObjectReference, names ...string) error + + // CreateInline creates a secret that is owned by the referenced object + // returns the name of the created secret or an error + CreateInline(ctx context.Context, owner common.ObjectReference, value common.RawSecureValue) (string, error) + + // DeleteWhenOwnedByResource removes secrets if and only if they are owned by a referenced object + DeleteWhenOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) error +} diff --git a/pkg/registry/apis/secret/service/consolidation.go b/pkg/registry/apis/secret/service/consolidation.go new file mode 100644 index 00000000000..b0baea659c4 --- /dev/null +++ b/pkg/registry/apis/secret/service/consolidation.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + otelcodes "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +type ConsolidationService struct { + tracer trace.Tracer + globalDataKeyStore contracts.GlobalDataKeyStorage + encryptedValueStore contracts.EncryptedValueStorage + globalEncryptedValueStore contracts.GlobalEncryptedValueStorage + encryptionManager contracts.EncryptionManager +} + +func ProvideConsolidationService( + tracer trace.Tracer, + globalDataKeyStore contracts.GlobalDataKeyStorage, + encryptedValueStore contracts.EncryptedValueStorage, + globalEncryptedValueStore contracts.GlobalEncryptedValueStorage, + encryptionManager contracts.EncryptionManager, +) contracts.ConsolidationService { + return &ConsolidationService{ + tracer: tracer, + globalDataKeyStore: globalDataKeyStore, + encryptedValueStore: encryptedValueStore, + globalEncryptedValueStore: globalEncryptedValueStore, + encryptionManager: encryptionManager, + } +} + +func (s *ConsolidationService) Consolidate(ctx context.Context) (err error) { + ctx, span := s.tracer.Start(ctx, "ConsolidationService.Consolidate") + defer span.End() + + defer func() { + if err != nil { + span.SetStatus(otelcodes.Error, err.Error()) + span.RecordError(err) + } + }() + + // Disable all active data keys. + // This will ensure that no new data can be encrypted with the old keys. + err = s.globalDataKeyStore.DisableAllDataKeys(ctx) + if err != nil { + return fmt.Errorf("disabling all data keys: %w", err) + } + + // List all encrypted values. + encryptedValues, err := s.globalEncryptedValueStore.ListAll(ctx, contracts.ListOpts{}, nil) + if err != nil { + return fmt.Errorf("listing all encrypted values: %w", err) + } + + for _, ev := range encryptedValues { + // Decrypt the value using its old data key. + decryptedValue, err := s.encryptionManager.Decrypt(ctx, ev.Namespace, ev.EncryptedData) + if err != nil { + logging.FromContext(ctx).Error("Failed to decrypt value", "namespace", ev.Namespace, "name", ev.Name, "error", err) + continue + } + + // Re-encrypt the value using a new data key. + reEncryptedValue, err := s.encryptionManager.Encrypt(ctx, ev.Namespace, decryptedValue) + if err != nil { + logging.FromContext(ctx).Error("Failed to re-encrypt value", "namespace", ev.Namespace, "name", ev.Name, "error", err) + continue + } + + // Update the encrypted value in the store. + err = s.encryptedValueStore.Update(ctx, ev.Namespace, ev.Name, ev.Version, reEncryptedValue) + if err != nil { + logging.FromContext(ctx).Error("Failed to update encrypted value", "namespace", ev.Namespace, "name", ev.Name, "error", err) + continue + } + } + + // TODO: After all values are re-encrypted, we can safely remove the old data keys. + + return nil +} diff --git a/pkg/registry/apis/secret/service/consolidation_test.go b/pkg/registry/apis/secret/service/consolidation_test.go new file mode 100644 index 00000000000..57ac94253c9 --- /dev/null +++ b/pkg/registry/apis/secret/service/consolidation_test.go @@ -0,0 +1,281 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/service" + "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +// mockGlobalEncryptedValueStorage wraps the real storage and allows injecting behavior during ListAll +type mockGlobalEncryptedValueStorage struct { + real contracts.GlobalEncryptedValueStorage + sut *testutils.Sut + ctx context.Context + onListAll func() +} + +func (m *mockGlobalEncryptedValueStorage) ListAll(ctx context.Context, opts contracts.ListOpts, untilTime *int64) ([]*contracts.EncryptedValue, error) { + if m.onListAll != nil { + m.onListAll() + } + return m.real.ListAll(ctx, opts, untilTime) +} + +func (m *mockGlobalEncryptedValueStorage) CountAll(ctx context.Context, untilTime *int64) (int64, error) { + return m.real.CountAll(ctx, untilTime) +} + +func TestConsolidation(t *testing.T) { + t.Parallel() + + t.Run("consolidation re-encrypts values but preserves decrypted content", func(t *testing.T) { + t.Parallel() + sut := testutils.Setup(t) + + ctx := context.Background() + createAuthContext := func(ctx context.Context, namespace string, identityType types.IdentityType) context.Context { + return types.WithAuthInfo(ctx, &identity.StaticRequester{ + Type: identityType, + Namespace: namespace, + AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{ + Rest: authn.AccessTokenClaims{ + Permissions: []string{"secret.grafana.app/securevalues:decrypt"}, + ServiceIdentity: "decrypter1", + }, + }, + }) + } + + // Create several secure values in different namespaces + testCases := []struct { + name string + namespace string + value string + }{ + {"test-secret-1", "namespace1", "test-value-1"}, + {"test-secret-2", "namespace1", "test-value-2"}, + {"test-secret-3", "namespace2", "test-value-3"}, + {"test-secret-4", "namespace2", "test-value-4"}, + } + + var originalDecryptedValues []string + var originalEncryptedData [][]byte + + // Create secure values and store their original decrypted values and encrypted data + for _, tc := range testCases { + sv := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.name, + Namespace: tc.namespace, + }, + Spec: secretv1beta1.SecureValueSpec{ + Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), + Decrypters: []string{"decrypter1"}, + }, + } + + createdSv, err := sut.CreateSv(ctx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + require.NotNil(t, createdSv) + + // Store the original decrypted data and encrypted data + authCtx := createAuthContext(ctx, tc.namespace, types.TypeAccessPolicy) + decryptedValue, err := sut.DecryptStorage.Decrypt(authCtx, xkube.Namespace(tc.namespace), tc.name) + require.NoError(t, err) + originalDecryptedValues = append(originalDecryptedValues, decryptedValue.DangerouslyExposeAndConsumeValue()) + + encryptedValue, err := sut.EncryptedValueStorage.Get(ctx, tc.namespace, tc.name, 1) + require.NoError(t, err) + require.NotNil(t, encryptedValue) + originalEncryptedData = append(originalEncryptedData, encryptedValue.EncryptedData) + } + + // Run consolidation + err := sut.ConsolidationService.Consolidate(ctx) + require.NoError(t, err) + + for i, tc := range testCases { + // Verify that the decrypted values are still the same + authCtx := createAuthContext(ctx, tc.namespace, types.TypeAccessPolicy) + decryptedValue, err := sut.DecryptStorage.Decrypt(authCtx, xkube.Namespace(tc.namespace), tc.name) + require.NoError(t, err) + require.Equal(t, originalDecryptedValues[i], decryptedValue.DangerouslyExposeAndConsumeValue()) + + // Verify that the encrypted data has changed (indicating re-encryption) + encryptedValue, err := sut.EncryptedValueStorage.Get(ctx, tc.namespace, tc.name, 1) + require.NoError(t, err) + require.NotEqual(t, originalEncryptedData[i], encryptedValue.EncryptedData) + } + }) + + t.Run("consolidation handles secrets created during the process", func(t *testing.T) { + t.Parallel() + sut := testutils.Setup(t) + + ctx := context.Background() + createAuthContext := func(ctx context.Context, namespace string, identityType types.IdentityType) context.Context { + return types.WithAuthInfo(ctx, &identity.StaticRequester{ + Type: identityType, + Namespace: namespace, + AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{ + Rest: authn.AccessTokenClaims{ + Permissions: []string{"secret.grafana.app/securevalues:decrypt"}, + ServiceIdentity: "decrypter1", + }, + }, + }) + } + + // Create initial secure values + initialSecrets := []struct { + name string + namespace string + value string + }{ + {"initial-secret-1", "namespace1", "initial-value-1"}, + {"initial-secret-2", "namespace2", "initial-value-2"}, + } + + var initialDecryptedValues []string + var initialEncryptedData [][]byte + + for _, tc := range initialSecrets { + sv := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.name, + Namespace: tc.namespace, + }, + Spec: secretv1beta1.SecureValueSpec{ + Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), + Decrypters: []string{"decrypter1"}, + }, + } + + _, err := sut.CreateSv(ctx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + // Store original decrypted values and encrypted data + authCtx := createAuthContext(ctx, tc.namespace, types.TypeAccessPolicy) + decryptedValue, err := sut.DecryptStorage.Decrypt(authCtx, xkube.Namespace(tc.namespace), tc.name) + require.NoError(t, err) + initialDecryptedValues = append(initialDecryptedValues, decryptedValue.DangerouslyExposeAndConsumeValue()) + + encryptedValue, err := sut.EncryptedValueStorage.Get(ctx, tc.namespace, tc.name, 1) + require.NoError(t, err) + initialEncryptedData = append(initialEncryptedData, encryptedValue.EncryptedData) + } + + // Secrets to be created during consolidation (after data keys are disabled) + var newSecretDecryptedValues []string + var newSecretEncryptedData [][]byte + + // Create a mock GlobalEncryptedValueStorage that will create new secrets when ListAll is called + mockStorage := &mockGlobalEncryptedValueStorage{ + real: sut.GlobalEncryptedValueStorage, + sut: &sut, + ctx: ctx, + onListAll: func() { + // This function is called during consolidation, after data keys are disabled + // but before the re-encryption loop begins + newSecrets := []struct { + name string + namespace string + value string + desc string + }{ + {"new-secret-1", "namespace1", "new-value-1", "New secret created during consolidation"}, + {"new-secret-2", "namespace3", "new-value-2", "Another new secret during consolidation"}, + } + + for _, tc := range newSecrets { + sv := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.name, + Namespace: tc.namespace, + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: tc.desc, + Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), + Decrypters: []string{"decrypter1"}, + }, + } + + _, err := sut.CreateSv(ctx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + // Store their decrypted values and original encrypted data + authCtx := createAuthContext(ctx, tc.namespace, types.TypeAccessPolicy) + decryptedValue, err := sut.DecryptStorage.Decrypt(authCtx, xkube.Namespace(tc.namespace), tc.name) + require.NoError(t, err) + newSecretDecryptedValues = append(newSecretDecryptedValues, decryptedValue.DangerouslyExposeAndConsumeValue()) + + encryptedValue, err := sut.EncryptedValueStorage.Get(ctx, tc.namespace, tc.name, 1) + require.NoError(t, err) + newSecretEncryptedData = append(newSecretEncryptedData, encryptedValue.EncryptedData) + } + }, + } + + // Create a custom consolidation service that uses the mocked storage + tracer := noop.NewTracerProvider().Tracer("test") + customConsolidationService := service.ProvideConsolidationService( + tracer, + sut.GlobalDataKeyStore, + sut.EncryptedValueStorage, + mockStorage, + sut.EncryptionManager, + ) + + // Run consolidation + err := customConsolidationService.Consolidate(ctx) + require.NoError(t, err) + + for i, tc := range initialSecrets { + // Verify that all initial secrets still decrypt to the same values + authCtx := createAuthContext(ctx, tc.namespace, types.TypeAccessPolicy) + decryptedValue, err := sut.DecryptStorage.Decrypt(authCtx, xkube.Namespace(tc.namespace), tc.name) + require.NoError(t, err) + require.Equal(t, initialDecryptedValues[i], decryptedValue.DangerouslyExposeAndConsumeValue()) + + // Verify that the encrypted data has changed (indicating re-encryption) + encryptedValue, err := sut.EncryptedValueStorage.Get(ctx, tc.namespace, tc.name, 1) + require.NoError(t, err) + require.NotEqual(t, initialEncryptedData[i], encryptedValue.EncryptedData) + } + + // Verify that the new secrets (created during consolidation) also decrypt correctly + // These secrets should have been re-encrypted as well during the consolidation process + newSecrets := []struct { + name string + namespace string + }{ + {"new-secret-1", "namespace1"}, + {"new-secret-2", "namespace3"}, + } + + for i, tc := range newSecrets { + authCtx := createAuthContext(ctx, tc.namespace, types.TypeAccessPolicy) + decryptedValue, err := sut.DecryptStorage.Decrypt(authCtx, xkube.Namespace(tc.namespace), tc.name) + require.NoError(t, err) + require.Equal(t, newSecretDecryptedValues[i], decryptedValue.DangerouslyExposeAndConsumeValue()) + + // Verify that the encrypted data has changed from what it was when first created + // (indicating it was re-encrypted during consolidation) + encryptedValue, err := sut.EncryptedValueStorage.Get(ctx, tc.namespace, tc.name, 1) + require.NoError(t, err) + require.NotEqual(t, newSecretEncryptedData[i], encryptedValue.EncryptedData) + } + }) +} diff --git a/pkg/registry/apis/secret/service/inline_secure_value.go b/pkg/registry/apis/secret/service/inline_secure_value.go new file mode 100644 index 00000000000..665bc622f54 --- /dev/null +++ b/pkg/registry/apis/secret/service/inline_secure_value.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/authlib/authn" + authlib "github.com/grafana/authlib/types" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/grafana/grafana/pkg/util" +) + +type inlineSecureValueService struct { + tracer trace.Tracer + secureValueService contracts.SecureValueService + accessChecker authlib.AccessChecker +} + +func ProvideInlineSecureValueService( + tracer trace.Tracer, + secureValueService contracts.SecureValueService, + accessClient authlib.AccessClient, +) contracts.InlineSecureValueSupport { + return &inlineSecureValueService{ + tracer: tracer, + secureValueService: secureValueService, + accessChecker: accessClient, + } +} + +func (s *inlineSecureValueService) CanReference(ctx context.Context, owner common.ObjectReference, names ...string) error { + ctx, span := s.tracer.Start(ctx, "InlineSecureValueService.CanReference", trace.WithAttributes( + attribute.String("owner.namespace", owner.Namespace), + attribute.String("owner.apiGroup", owner.APIGroup), + attribute.String("owner.apiVersion", owner.APIVersion), + attribute.String("owner.kind", owner.Kind), + attribute.String("owner.name", owner.Name), + attribute.StringSlice("secureValueNames", names), + )) + defer span.End() + + authInfo, ok := authlib.AuthInfoFrom(ctx) + if !ok { + return fmt.Errorf("missing auth info in context") + } + + if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) { + return fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace()) + } + + if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" { + return fmt.Errorf("owner reference must have a valid API group, API version, kind and name") + } + + if len(names) == 0 { + return fmt.Errorf("no inline secure values provided") + } + + for _, name := range names { + if name == "" { + return fmt.Errorf("empty secure value name") + } + + owned, err := s.isSecureValueOwnedByResource(ctx, owner, name) + if err != nil { + return err + } + + if !owned { + if err := s.canIdentityReadSecureValue(ctx, xkube.Namespace(owner.Namespace), name); err != nil { + return err + } + } + } + + return nil +} + +func (s *inlineSecureValueService) isSecureValueOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) (bool, error) { + sv, err := s.secureValueService.Read(ctx, xkube.Namespace(owner.Namespace), name) + if err != nil { + if errors.Is(err, contracts.ErrSecureValueNotFound) { + return false, err + } + + return false, fmt.Errorf("error reading secure value %s: %w", name, err) + } + + secureValueOwners := sv.GetOwnerReferences() + if len(secureValueOwners) > 1 { + return false, fmt.Errorf("bug found: secure value %s with multiple owners, expected only one", name) + } + + if len(secureValueOwners) == 1 { + actualOwner := secureValueOwners[0] + + gv, err := schema.ParseGroupVersion(actualOwner.APIVersion) + if err != nil { + return false, fmt.Errorf("bug found: secure value %s should have valid group version here: %w", name, err) + } + if gv.Group == "" { + return false, fmt.Errorf("bug found: secure value %s should have a non-empty group in the owner reference", name) + } + + sameOwner := owner.APIGroup == gv.Group && owner.Kind == actualOwner.Kind && owner.Name == actualOwner.Name + if sameOwner { + return true, nil // The secure value is owned by the same owner reference, pass! + } + + return false, fmt.Errorf("secure value %s is not owned by %v but by %v", name, owner, actualOwner) + } + + // not owned + return false, nil +} + +func (s *inlineSecureValueService) canIdentityReadSecureValue(ctx context.Context, namespace xkube.Namespace, name string) error { + authInfo, ok := authlib.AuthInfoFrom(ctx) + if !ok { + return fmt.Errorf("missing auth info in context") + } + + // If the secure value is shared, we always need a user/svc account in the context. + if authInfo.GetIdentityType() != authlib.TypeUser && authInfo.GetIdentityType() != authlib.TypeServiceAccount { + return fmt.Errorf("identity type %s not allowed, expected either %s or %s", authInfo.GetIdentityType(), authlib.TypeUser, authlib.TypeServiceAccount) + } + + resp, err := s.accessChecker.Check(ctx, authInfo, authlib.CheckRequest{ + Verb: utils.VerbGet, + Group: secretv1beta1.APIGroup, + Resource: secretv1beta1.SecureValuesResourceInfo.GroupResource().Resource, + Namespace: namespace.String(), + Name: name, + }) + if err != nil { + return fmt.Errorf("checking access for secure value %s: %w", name, err) + } + + if !resp.Allowed { + return fmt.Errorf("identity is not allowed to reference secure value %s", name) + } + + return nil +} + +func (s *inlineSecureValueService) CreateInline(ctx context.Context, owner common.ObjectReference, value common.RawSecureValue) (string, error) { + ctx, span := s.tracer.Start(ctx, "InlineSecureValueService.CreateInline", trace.WithAttributes( + attribute.String("owner.namespace", owner.Namespace), + attribute.String("owner.apiGroup", owner.APIGroup), + attribute.String("owner.apiVersion", owner.APIVersion), + attribute.String("owner.kind", owner.Kind), + attribute.String("owner.name", owner.Name), + )) + defer span.End() + + authInfo, ok := authlib.AuthInfoFrom(ctx) + if !ok { + return "", fmt.Errorf("missing auth info in context") + } + + if authInfo.GetIdentityType() != authlib.TypeUser && authInfo.GetIdentityType() != authlib.TypeServiceAccount { + return "", fmt.Errorf("identity type %s not allowed, expected either %s or %s", authInfo.GetIdentityType(), authlib.TypeUser, authlib.TypeServiceAccount) + } + + serviceIdentityList, ok := authInfo.GetExtra()[authn.ServiceIdentityKey] + if !ok || len(serviceIdentityList) != 1 { + return "", fmt.Errorf("expected exactly one service identity, found %d", len(serviceIdentityList)) + } + serviceIdentity := serviceIdentityList[0] + + if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) { + return "", fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace()) + } + + if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" { + return "", fmt.Errorf("owner reference must have a valid API group, API version, kind and name") + } + + if value.IsZero() { + return "", fmt.Errorf("trying to create an inline secure value with empty value") + } + + // TODO(2025-07-31): when we migrate to using the common type, we don't need this conversion. + secret := secretv1beta1.ExposedSecureValue(value) + + spec := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv-" + util.GenerateShortUID(), + Namespace: owner.Namespace, + OwnerReferences: []metav1.OwnerReference{owner.ToOwnerReference()}, + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: fmt.Sprintf("Inline secure value for %s/%s in %s/%s", owner.Kind, owner.Name, owner.APIVersion, owner.APIVersion), + Value: &secret, + Decrypters: []string{ + serviceIdentity, + }, + }, + } + + createdSv, err := s.secureValueService.Create(ctx, spec, authInfo.GetUID()) + if err != nil { + return "", fmt.Errorf("error creating secure value %s for owner %v: %w", spec.Name, owner, err) + } + + return createdSv.GetName(), nil +} + +func (s *inlineSecureValueService) DeleteWhenOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) error { + ctx, span := s.tracer.Start(ctx, "InlineSecureValueService.DeleteWhenOwnedByResource", trace.WithAttributes( + attribute.String("owner.namespace", owner.Namespace), + attribute.String("owner.apiGroup", owner.APIGroup), + attribute.String("owner.apiVersion", owner.APIVersion), + attribute.String("owner.kind", owner.Kind), + attribute.String("owner.name", owner.Name), + attribute.String("secureValue.name", name), + )) + defer span.End() + + authInfo, ok := authlib.AuthInfoFrom(ctx) + if !ok { + return fmt.Errorf("missing auth info in context") + } + + if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) { + return fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace()) + } + + if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" { + return fmt.Errorf("owner reference must have a valid API group, API version, kind and name") + } + + owned, err := s.isSecureValueOwnedByResource(ctx, owner, name) + if err != nil { + return fmt.Errorf("error checking if secure value %s is owned by %v: %w", name, owner, err) + } + + if owned { + if _, err := s.secureValueService.Delete(ctx, xkube.Namespace(owner.Namespace), name); err != nil { + return fmt.Errorf("error deleting secure value %s for owner %v: %w", name, owner, err) + } + } + + // if it is not owned, this is a no-op + return nil +} diff --git a/pkg/registry/apis/secret/service/inline_secure_value_test.go b/pkg/registry/apis/secret/service/inline_secure_value_test.go new file mode 100644 index 00000000000..3c8742bffb3 --- /dev/null +++ b/pkg/registry/apis/secret/service/inline_secure_value_test.go @@ -0,0 +1,512 @@ +package service_test + +import ( + "testing" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/service" + "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestIntegration_InlineSecureValue_CanReference(t *testing.T) { + t.Parallel() + + tracer := noop.NewTracerProvider().Tracer("test") + + defaultNs := "org-1234" + owner := common.ObjectReference{ + APIGroup: "prometheus.datasource.grafana.app", + APIVersion: "v1alpha1", + Kind: "DataSourceConfig", + Name: "test-datasource", + Namespace: defaultNs, + } + + t.Run("happy path with owned and shared secure values", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + sv1 := "test-secure-value-1" + createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + cfg.Sv.OwnerReferences = []metav1.OwnerReference{owner.ToOwnerReference()} + }) + require.NoError(t, err) + require.NotNil(t, createdSv1) + + sv2 := "test-secure-value-2" + createdSv2, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv2 + cfg.Sv.Namespace = defaultNs + }) + require.NoError(t, err) + require.NotNil(t, createdSv2) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{ + "securevalues:read": {"securevalues:uid:" + sv2}, + }) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, tu.AccessClient) + + err = svc.CanReference(ctx, owner, sv1, sv2) + require.NoError(t, err) + }) + + t.Run("when the auth info is missing it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + err := svc.CanReference(t.Context(), common.ObjectReference{}) + require.Error(t, err) + }) + + t.Run("when the owner namespace does not match auth info namespace it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + reqNs := "org-2345" + ctx := testutils.CreateUserAuthContext(t.Context(), reqNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner) + require.Error(t, err) + }) + + t.Run("when the owner namespace is empty it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, common.ObjectReference{}) + require.Error(t, err) + }) + + t.Run("when the owner reference has empty fields it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + owner := common.ObjectReference{ + Namespace: defaultNs, + } + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner) + require.Error(t, err) + + owner.APIGroup = "prometheus.datasource.grafana.app" + require.Error(t, svc.CanReference(ctx, owner)) + owner.APIGroup = "" + + owner.APIVersion = "v1alpha1" + require.Error(t, svc.CanReference(ctx, owner)) + owner.APIVersion = "" + + owner.Kind = "DataSourceConfig" + require.Error(t, svc.CanReference(ctx, owner)) + owner.Kind = "" + + owner.Name = "test-datasource" + require.Error(t, svc.CanReference(ctx, owner)) + owner.Name = "" + }) + + t.Run("when no secure values are provided it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner) + require.Error(t, err) + }) + + t.Run("when the secure value does not exist, it returns an error", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.CanReference(ctx, owner, "non-existent-sv") + require.Error(t, err) + }) + + t.Run("when the secure value is owned by a different resource, it returns an error", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + differentOwner := common.ObjectReference{ + APIGroup: "prometheus.datasource.grafana.app", + APIVersion: "v1alpha1", + Kind: "DataSourceConfig", + Name: "different-datasource", + Namespace: defaultNs, + } + + sv1 := "test-secure-value-1" + createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + cfg.Sv.OwnerReferences = []metav1.OwnerReference{differentOwner.ToOwnerReference()} + }) + require.NoError(t, err) + require.NotNil(t, createdSv1) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + err = svc.CanReference(ctx, owner, sv1) + require.Error(t, err) + }) + + t.Run("when the request identity is not a user nor a service account, it returns an error", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + sv1 := "test-secure-value-1" + _, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + }) + require.NoError(t, err) + + ctx := identity.WithServiceIdentityContext(t.Context(), 1234) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + err = svc.CanReference(ctx, owner, sv1) + require.Error(t, err) + }) + + t.Run("when the identity does not have permissions to read the secure value, it returns an error", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + sv1 := "test-secure-value-1" + _, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + }) + require.NoError(t, err) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, tu.AccessClient) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{ + "securevalues:read": {"securevalues:uid:another-sv"}, // can read, but another resource! + }) + + err = svc.CanReference(ctx, owner, sv1) + require.Error(t, err) + + ctx = testutils.CreateUserAuthContext(t.Context(), defaultNs, nil) + + err = svc.CanReference(ctx, owner, sv1) + require.Error(t, err) + }) +} + +func TestIntegration_InlineSecureValue_CreateInline(t *testing.T) { + t.Parallel() + + tracer := noop.NewTracerProvider().Tracer("test") + + defaultNs := "org-1234" + owner := common.ObjectReference{ + APIGroup: "prometheus.datasource.grafana.app", + APIVersion: "v1alpha1", + Kind: "DataSourceConfig", + Name: "test-datasource", + Namespace: defaultNs, + } + + t.Run("happy path creates an inline secure value", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + secret := common.NewSecretValue("test-value") + + serviceIdentity := "service-identity" + + createAuthCtx := testutils.CreateOBOAuthContext(t.Context(), serviceIdentity, owner.Namespace, nil, nil) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + createdName, err := svc.CreateInline(createAuthCtx, owner, secret) + require.NoError(t, err) + require.NotEmpty(t, createdName) + + decryptAuthCtx := testutils.CreateServiceAuthContext(t.Context(), serviceIdentity, owner.Namespace, []string{"secret.grafana.app/securevalues:decrypt"}) + + decryptedValues, err := tu.DecryptService.Decrypt(decryptAuthCtx, owner.Namespace, createdName) + require.NoError(t, err) + + decryptedResult, ok := decryptedValues[createdName] + require.True(t, ok) + require.Equal(t, decryptedResult.Value().DangerouslyExposeAndConsumeValue(), secret.DangerouslyExposeAndConsumeValue()) + }) + + t.Run("when the auth info is missing it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + _, err := svc.CreateInline(t.Context(), common.ObjectReference{}, "") + require.Error(t, err) + }) + + t.Run("when the request identity is not a user nor a service account, it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + createAuthCtx := testutils.CreateServiceAuthContext(t.Context(), "service-identity", defaultNs, nil) + + _, err := svc.CreateInline(createAuthCtx, common.ObjectReference{}, "") + require.Error(t, err) + }) + + t.Run("when the owner namespace does not match auth info namespace it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + reqNs := "org-2345" + createAuthCtx := testutils.CreateOBOAuthContext(t.Context(), "service-identity", reqNs, nil, nil) + + _, err := svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + }) + + t.Run("when the owner namespace is empty it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + createAuthCtx := testutils.CreateOBOAuthContext(t.Context(), "service-identity", defaultNs, nil, nil) + + _, err := svc.CreateInline(createAuthCtx, common.ObjectReference{}, "") + require.Error(t, err) + }) + + t.Run("when the owner reference has empty fields it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + owner := common.ObjectReference{ + Namespace: defaultNs, + } + + createAuthCtx := testutils.CreateOBOAuthContext(t.Context(), "service-identity", defaultNs, nil, nil) + + _, err := svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + + owner.APIGroup = "prometheus.datasource.grafana.app" + _, err = svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + + owner.APIVersion = "v1alpha1" + _, err = svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + + owner.Kind = "DataSourceConfig" + _, err = svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + owner.Kind = "" + + owner.Name = "test-datasource" + _, err = svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + }) + + t.Run("when an empty secret is provided it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + createAuthCtx := testutils.CreateOBOAuthContext(t.Context(), "service-identity", defaultNs, nil, nil) + + _, err := svc.CreateInline(createAuthCtx, owner, "") + require.Error(t, err) + }) +} + +func TestIntegration_InlineSecureValue_DeleteWhenOwnedByResource(t *testing.T) { + t.Parallel() + + tracer := noop.NewTracerProvider().Tracer("test") + + defaultNs := "org-1234" + owner := common.ObjectReference{ + APIGroup: "prometheus.datasource.grafana.app", + APIVersion: "v1alpha1", + Kind: "DataSourceConfig", + Name: "test-datasource", + Namespace: defaultNs, + } + + t.Run("happy path deletes an owned secure value", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + sv1 := "test-secure-value-1" + createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + cfg.Sv.OwnerReferences = []metav1.OwnerReference{owner.ToOwnerReference()} + }) + require.NoError(t, err) + require.NotNil(t, createdSv1) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + ctx := testutils.CreateServiceAuthContext(t.Context(), "", defaultNs, nil) + + err = svc.DeleteWhenOwnedByResource(ctx, owner, sv1) + require.NoError(t, err) + + // make sure it got deleted + sv, err := tu.SecureValueService.Read(ctx, xkube.Namespace(owner.Namespace), sv1) + require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) + require.Nil(t, sv) + }) + + t.Run("when the auth info is missing it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + err := svc.DeleteWhenOwnedByResource(t.Context(), common.ObjectReference{}, "") + require.Error(t, err) + }) + + t.Run("when the owner namespace does not match auth info namespace it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + reqNs := "org-2345" + ctx := testutils.CreateUserAuthContext(t.Context(), reqNs, map[string][]string{}) + + err := svc.DeleteWhenOwnedByResource(ctx, owner, "") + require.Error(t, err) + }) + + t.Run("when the owner namespace is empty it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{}) + + err := svc.DeleteWhenOwnedByResource(ctx, common.ObjectReference{}, "") + require.Error(t, err) + }) + + t.Run("when the owner reference has empty fields it returns an error", func(t *testing.T) { + t.Parallel() + + svc := service.ProvideInlineSecureValueService(tracer, nil, nil) + + owner := common.ObjectReference{ + Namespace: defaultNs, + } + + createAuthCtx := testutils.CreateOBOAuthContext(t.Context(), "service-identity", defaultNs, nil, nil) + + require.Error(t, svc.DeleteWhenOwnedByResource(createAuthCtx, owner, "")) + + owner.APIGroup = "prometheus.datasource.grafana.app" + require.Error(t, svc.DeleteWhenOwnedByResource(createAuthCtx, owner, "")) + + owner.APIVersion = "v1alpha1" + require.Error(t, svc.DeleteWhenOwnedByResource(createAuthCtx, owner, "")) + + owner.Kind = "DataSourceConfig" + require.Error(t, svc.DeleteWhenOwnedByResource(createAuthCtx, owner, "")) + owner.Kind = "" + + owner.Name = "test-datasource" + require.Error(t, svc.DeleteWhenOwnedByResource(createAuthCtx, owner, "")) + }) + + t.Run("when the secure value exists but the owner does not match, it returns an error", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + sv1 := "test-secure-value-1" + createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + cfg.Sv.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "another.example.com/v0alpha1", + Kind: "another-kind", + Name: "another-name", + }, + } + }) + require.NoError(t, err) + require.NotNil(t, createdSv1) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + ctx := testutils.CreateServiceAuthContext(t.Context(), "", defaultNs, nil) + + err = svc.DeleteWhenOwnedByResource(ctx, owner, sv1) + require.Error(t, err) + + // make sure it still exists + sv, err := tu.SecureValueService.Read(ctx, xkube.Namespace(owner.Namespace), sv1) + require.NoError(t, err) + require.NotNil(t, sv) + require.Equal(t, sv1, sv.GetName()) + }) + + t.Run("when the secure value exists but it is shared (no owner), it does not return an error (noop)", func(t *testing.T) { + t.Parallel() + + tu := testutils.Setup(t) + + sv1 := "test-secure-value-1" + createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) { + cfg.Sv.Name = sv1 + cfg.Sv.Namespace = defaultNs + }) + require.NoError(t, err) + require.NotNil(t, createdSv1) + + svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil) + + ctx := testutils.CreateServiceAuthContext(t.Context(), "", defaultNs, nil) + + err = svc.DeleteWhenOwnedByResource(ctx, owner, sv1) + require.NoError(t, err) + + // make sure it still exists + sv, err := tu.SecureValueService.Read(ctx, xkube.Namespace(owner.Namespace), sv1) + require.NoError(t, err) + require.NotNil(t, sv) + require.Equal(t, sv1, sv.GetName()) + }) +} diff --git a/pkg/registry/apis/secret/service/metrics/metrics.go b/pkg/registry/apis/secret/service/metrics/metrics.go new file mode 100644 index 00000000000..b179ead20b7 --- /dev/null +++ b/pkg/registry/apis/secret/service/metrics/metrics.go @@ -0,0 +1,128 @@ +package metrics + +import ( + "sync" + + "github.com/prometheus/client_golang/prometheus" +) + +const ( + namespace = "grafana_secrets_manager" + subsystem = "service" +) + +// SecureValueServiceMetrics is a struct that contains all the metrics for SecureValue. +type SecureValueServiceMetrics struct { + SecureValueCreateDuration *prometheus.HistogramVec + SecureValueCreateCount *prometheus.CounterVec + SecureValueUpdateDuration *prometheus.HistogramVec + SecureValueUpdateCount *prometheus.CounterVec + SecureValueReadDuration *prometheus.HistogramVec + SecureValueReadCount *prometheus.CounterVec + SecureValueListDuration *prometheus.HistogramVec + SecureValueListCount *prometheus.CounterVec + SecureValueDeleteDuration *prometheus.HistogramVec + SecureValueDeleteCount *prometheus.CounterVec +} + +func newSecureValueServiceMetrics() *SecureValueServiceMetrics { + return &SecureValueServiceMetrics{ + SecureValueCreateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_create_duration_seconds", + Help: "Duration of Secure Value create operations", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + SecureValueCreateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_create_count", + Help: "Count of Secure Value create operations", + }, []string{"success"}), + SecureValueReadDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_read_duration_seconds", + Help: "Duration of Secure Value read operations", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + SecureValueReadCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_read_count", + Help: "Count of Secure Value read operations", + }, []string{"success"}), + SecureValueUpdateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_update_duration_seconds", + Help: "Duration of Secure Value update operations", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + SecureValueUpdateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_update_count", + Help: "Count of Secure Value update operations", + }, []string{"success"}), + SecureValueListDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_list_duration_seconds", + Help: "Duration of Secure Value list operations", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + SecureValueListCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_list_count", + Help: "Count of Secure Value list operations", + }, []string{"success"}), + SecureValueDeleteDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_delete_duration_seconds", + Help: "Duration of Secure Value delete operations", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + SecureValueDeleteCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "secure_value_delete_count", + Help: "Count of Secure Value delete operations", + }, []string{"success"}), + } +} + +var ( + initOnce sync.Once + metricsInstance *SecureValueServiceMetrics +) + +func NewSecureValueServiceMetrics(reg prometheus.Registerer) *SecureValueServiceMetrics { + initOnce.Do(func() { + m := newSecureValueServiceMetrics() + + if reg != nil { + reg.MustRegister( + m.SecureValueCreateDuration, + m.SecureValueCreateCount, + m.SecureValueReadDuration, + m.SecureValueReadCount, + m.SecureValueUpdateDuration, + m.SecureValueUpdateCount, + m.SecureValueListDuration, + m.SecureValueListCount, + m.SecureValueDeleteDuration, + m.SecureValueDeleteCount, + ) + } + metricsInstance = m + }) + return metricsInstance +} + +func NewTestMetrics() *SecureValueServiceMetrics { + return newSecureValueServiceMetrics() +} diff --git a/pkg/registry/apis/secret/service/secure_value.go b/pkg/registry/apis/secret/service/secure_value.go index f9bafad9467..fe32208562d 100644 --- a/pkg/registry/apis/secret/service/secure_value.go +++ b/pkg/registry/apis/secret/service/secure_value.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "strconv" + "time" claims "github.com/grafana/authlib/types" "go.opentelemetry.io/otel/attribute" @@ -12,9 +14,14 @@ import ( secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/service/metrics" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/codes" ) +var _ contracts.SecureValueService = (*SecureValueService)(nil) + type SecureValueService struct { tracer trace.Tracer accessClient claims.AccessClient @@ -22,6 +29,7 @@ type SecureValueService struct { secureValueMetadataStorage contracts.SecureValueMetadataStorage keeperMetadataStorage contracts.KeeperMetadataStorage keeperService contracts.KeeperService + metrics *metrics.SecureValueServiceMetrics } func ProvideSecureValueService( @@ -31,6 +39,7 @@ func ProvideSecureValueService( secureValueMetadataStorage contracts.SecureValueMetadataStorage, keeperMetadataStorage contracts.KeeperMetadataStorage, keeperService contracts.KeeperService, + reg prometheus.Registerer, ) contracts.SecureValueService { return &SecureValueService{ tracer: tracer, @@ -39,27 +48,77 @@ func ProvideSecureValueService( secureValueMetadataStorage: secureValueMetadataStorage, keeperMetadataStorage: keeperMetadataStorage, keeperService: keeperService, + metrics: metrics.NewSecureValueServiceMetrics(reg), } } -func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) { +func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, createErr error) { + start := time.Now() + name, namespace := sv.GetName(), sv.GetNamespace() ctx, span := s.tracer.Start(ctx, "SecureValueService.Create", trace.WithAttributes( - attribute.String("name", sv.GetName()), - attribute.String("namespace", sv.GetNamespace()), + attribute.String("name", name), + attribute.String("namespace", namespace), attribute.String("actor", actorUID), )) defer span.End() + + defer func() { + args := []any{ + "name", name, + "namespace", namespace, + "actorUID", actorUID, + } + + success := createErr == nil + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "SecureValueService.Create failed") + span.RecordError(createErr) + args = append(args, "error", createErr) + } + + logging.FromContext(ctx).Info("SecureValueService.Create finished", args...) + + s.metrics.SecureValueCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + s.metrics.SecureValueCreateCount.WithLabelValues(strconv.FormatBool(success)).Inc() + }() + return s.createNewVersion(ctx, sv, actorUID) } -func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error) { +func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, sync bool, updateErr error) { + start := time.Now() + name, namespace := newSecureValue.GetName(), newSecureValue.GetNamespace() + ctx, span := s.tracer.Start(ctx, "SecureValueService.Update", trace.WithAttributes( - attribute.String("name", newSecureValue.GetName()), - attribute.String("namespace", newSecureValue.GetNamespace()), + attribute.String("name", name), + attribute.String("namespace", namespace), attribute.String("actor", actorUID), )) defer span.End() + defer func() { + args := []any{ + "name", name, + "namespace", namespace, + "actorUID", actorUID, + "sync", sync, + } + + success := updateErr == nil + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "SecureValueService.Update failed") + span.RecordError(updateErr) + args = append(args, "error", updateErr) + } + + logging.FromContext(ctx).Info("SecureValueService.Update finished", args...) + + s.metrics.SecureValueUpdateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + s.metrics.SecureValueUpdateCount.WithLabelValues(strconv.FormatBool(success)).Inc() + }() + if newSecureValue.Spec.Value == nil { currentVersion, err := s.secureValueMetadataStorage.Read(ctx, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, contracts.ReadOpts{}) if err != nil { @@ -136,22 +195,66 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1b return createdSv, nil } -func (s *SecureValueService) Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error) { +func (s *SecureValueService) Read(ctx context.Context, namespace xkube.Namespace, name string) (_ *secretv1beta1.SecureValue, readErr error) { + start := time.Now() + ctx, span := s.tracer.Start(ctx, "SecureValueService.Read", trace.WithAttributes( attribute.String("name", name), attribute.String("namespace", namespace.String()), )) + + defer func() { + args := []any{ + "name", name, + "namespace", namespace, + } + + success := readErr == nil + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "SecureValueService.Read failed") + span.RecordError(readErr) + args = append(args, "error", readErr) + } + + logging.FromContext(ctx).Info("SecureValueService.Read finished", args...) + + s.metrics.SecureValueReadDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + s.metrics.SecureValueReadCount.WithLabelValues(strconv.FormatBool(success)).Inc() + }() + defer span.End() return s.secureValueMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{ForUpdate: false}) } -func (s *SecureValueService) List(ctx context.Context, namespace xkube.Namespace) (*secretv1beta1.SecureValueList, error) { +func (s *SecureValueService) List(ctx context.Context, namespace xkube.Namespace) (_ *secretv1beta1.SecureValueList, listErr error) { + start := time.Now() + ctx, span := s.tracer.Start(ctx, "SecureValueService.List", trace.WithAttributes( attribute.String("namespace", namespace.String()), )) defer span.End() + defer func() { + args := []any{ + "namespace", namespace, + } + + success := listErr == nil + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "SecureValueService.List failed") + span.RecordError(listErr) + args = append(args, "error", listErr) + } + + logging.FromContext(ctx).Info("SecureValueService.List finished", args...) + + s.metrics.SecureValueListDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + s.metrics.SecureValueListCount.WithLabelValues(strconv.FormatBool(success)).Inc() + }() + user, ok := claims.AuthInfoFrom(ctx) if !ok { return nil, fmt.Errorf("missing auth info in context") @@ -188,13 +291,35 @@ func (s *SecureValueService) List(ctx context.Context, namespace xkube.Namespace }, nil } -func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error) { +func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespace, name string) (_ *secretv1beta1.SecureValue, deleteErr error) { + start := time.Now() + ctx, span := s.tracer.Start(ctx, "SecureValueService.Delete", trace.WithAttributes( attribute.String("name", name), attribute.String("namespace", namespace.String()), )) defer span.End() + defer func() { + args := []any{ + "name", name, + "namespace", namespace, + } + + success := deleteErr == nil + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "SecureValueService.Delete failed") + span.RecordError(deleteErr) + args = append(args, "error", deleteErr) + } + + logging.FromContext(ctx).Info("SecureValueService.Delete finished", args...) + + s.metrics.SecureValueDeleteDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + s.metrics.SecureValueDeleteCount.WithLabelValues(strconv.FormatBool(success)).Inc() + }() + // TODO: does this need to be for update? sv, err := s.secureValueMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{ForUpdate: true}) if err != nil { diff --git a/pkg/registry/apis/secret/testutils/testutils.go b/pkg/registry/apis/secret/testutils/testutils.go index f2d24b05905..3e742439bb0 100644 --- a/pkg/registry/apis/secret/testutils/testutils.go +++ b/pkg/registry/apis/secret/testutils/testutils.go @@ -87,6 +87,9 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { store, err := encryptionstorage.ProvideDataKeyStorage(database, tracer, nil) require.NoError(t, err) + globalDataKeyStore, err := encryptionstorage.ProvideGlobalDataKeyStorage(database, tracer, nil) + require.NoError(t, err) + usageStats := &usagestats.UsageStatsMock{T: t} enc, err := cipher.ProvideAESGCMCipherService(tracer, usageStats) @@ -120,7 +123,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { keeperService = setupCfg.KeeperService } - secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, keeperMetadataStorage, keeperService) + secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, keeperMetadataStorage, keeperService, nil) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer) @@ -135,6 +138,8 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { decryptService, err := decrypt.ProvideDecryptService(testCfg, tracer, decryptStorage) require.NoError(t, err) + consolidationService := service.ProvideConsolidationService(tracer, globalDataKeyStore, encryptedValueStorage, globalEncryptedValueStorage, encryptionManager) + return Sut{ SecureValueService: secureValueService, SecureValueMetadataStorage: secureValueMetadataStorage, @@ -145,6 +150,9 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { SQLKeeper: sqlKeeper, Database: database, AccessClient: accessClient, + ConsolidationService: consolidationService, + EncryptionManager: encryptionManager, + GlobalDataKeyStore: globalDataKeyStore, } } @@ -158,6 +166,9 @@ type Sut struct { SQLKeeper *sqlkeeper.SQLKeeper Database *database.Database AccessClient types.AccessClient + ConsolidationService contracts.ConsolidationService + EncryptionManager contracts.EncryptionManager + GlobalDataKeyStore contracts.GlobalDataKeyStorage } type CreateSvConfig struct { @@ -233,8 +244,9 @@ func CreateUserAuthContext(ctx context.Context, namespace string, permissions ma return types.WithAuthInfo(ctx, requester) } -func CreateServiceAuthContext(ctx context.Context, serviceIdentity string, permissions []string) context.Context { +func CreateServiceAuthContext(ctx context.Context, serviceIdentity string, namespace string, permissions []string) context.Context { requester := &identity.StaticRequester{ + Namespace: namespace, AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{ Rest: authn.AccessTokenClaims{ Permissions: permissions, @@ -245,3 +257,32 @@ func CreateServiceAuthContext(ctx context.Context, serviceIdentity string, permi return types.WithAuthInfo(ctx, requester) } + +// CreateOBOAuthContext emulates a context where the request is made on-behalf-of (OBO) a user, with an access token. +func CreateOBOAuthContext( + ctx context.Context, + serviceIdentity string, + namespace string, + userPermissions map[string][]string, + delegatedPermissions []string, +) context.Context { + requester := &identity.StaticRequester{ + Namespace: namespace, + Type: types.TypeUser, + UserID: 1, + Permissions: map[int64]map[string][]string{ + 1: userPermissions, + }, + AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{ + Rest: authn.AccessTokenClaims{ + ServiceIdentity: serviceIdentity, + DelegatedPermissions: delegatedPermissions, + Actor: &authn.ActorClaims{ + Subject: "user:1", + }, + }, + }, + } + + return types.WithAuthInfo(ctx, requester) +} diff --git a/pkg/registry/apps/playlist/register.go b/pkg/registry/apps/playlist/register.go index 4374c0265dd..123974c2dcc 100644 --- a/pkg/registry/apps/playlist/register.go +++ b/pkg/registry/apps/playlist/register.go @@ -26,7 +26,6 @@ import ( var ( _ appsdkapiserver.AppInstaller = (*PlaylistAppInstaller)(nil) _ appinstaller.LegacyStorageProvider = (*PlaylistAppInstaller)(nil) - _ appinstaller.APIEnablementProvider = (*PlaylistAppInstaller)(nil) ) type PlaylistAppInstaller struct { @@ -102,10 +101,3 @@ func (p *PlaylistAppInstaller) GetLegacyStorage(requested schema.GroupVersionRes ) return legacyStore } - -// GetAllowedV0Alpha1Resources returns the list of resources that are allowed to be accessed in v0alpha1. -func (p *PlaylistAppInstaller) GetAllowedV0Alpha1Resources() []string { - return []string{ - playlistv0alpha1.PlaylistKind().Plural(), - } -} diff --git a/pkg/server/runner.go b/pkg/server/runner.go index ae9ccf28469..cc31af9711b 100644 --- a/pkg/server/runner.go +++ b/pkg/server/runner.go @@ -8,32 +8,36 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" ) type Runner struct { - Cfg *setting.Cfg - SQLStore db.DB - SettingsProvider setting.Provider - Features featuremgmt.FeatureToggles - EncryptionService encryption.Internal - SecretsService *manager.SecretsService - SecretsMigrator secrets.Migrator - UserService user.Service + Cfg *setting.Cfg + SQLStore db.DB + SettingsProvider setting.Provider + Features featuremgmt.FeatureToggles + EncryptionService encryption.Internal + SecretsService *manager.SecretsService + SecretsMigrator secrets.Migrator + UserService user.Service + SecretsConsolidationService contracts.ConsolidationService } func NewRunner(cfg *setting.Cfg, sqlStore db.DB, settingsProvider setting.Provider, encryptionService encryption.Internal, features featuremgmt.FeatureToggles, secretsService *manager.SecretsService, secretsMigrator secrets.Migrator, - userService user.Service, + userService user.Service, secretsConsolidationService contracts.ConsolidationService, ) Runner { return Runner{ - Cfg: cfg, - SQLStore: sqlStore, - SettingsProvider: settingsProvider, - EncryptionService: encryptionService, - SecretsService: secretsService, - SecretsMigrator: secretsMigrator, - Features: features, - UserService: userService, + Cfg: cfg, + SQLStore: sqlStore, + SettingsProvider: settingsProvider, + EncryptionService: encryptionService, + SecretsService: secretsService, + SecretsMigrator: secretsMigrator, + Features: features, + UserService: userService, + SecretsConsolidationService: secretsConsolidationService, } } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 741ec53ede8..a56c1bff1a6 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -429,7 +429,9 @@ var wireBasicSet = wire.NewSet( secretdecrypt.ProvideDecryptAuthorizer, secretdecrypt.ProvideDecryptService, secretencryption.ProvideDataKeyStorage, + secretencryption.ProvideGlobalDataKeyStorage, secretencryption.ProvideEncryptedValueStorage, + secretencryption.ProvideGlobalEncryptedValueStorage, secretsecurevalueservice.ProvideSecureValueService, secretvalidator.ProvideKeeperValidator, secretvalidator.ProvideSecureValueValidator, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index b398b85d5a9..cf76c869512 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -746,7 +746,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser } userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) factory := github.ProvideFactory() - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl) + legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl, featureToggles) databaseDatabase := database5.ProvideDatabase(sqlStore, tracer) secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, tracer, registerer) if err != nil { @@ -780,7 +780,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - secureValueService := service12.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService) + secureValueService := service12.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService, registerer) secureValueValidator := validator3.ProvideSecureValueValidator() secureValueClient := secret.ProvideSecureValueClient(secureValueService, secureValueValidator, accessClient) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer) @@ -1307,7 +1307,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { } userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) factory := github.ProvideFactory() - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl) + legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl, featureToggles) databaseDatabase := database5.ProvideDatabase(sqlStore, tracer) secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, tracer, registerer) if err != nil { @@ -1341,7 +1341,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - secureValueService := service12.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService) + secureValueService := service12.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService, registerer) secureValueValidator := validator3.ProvideSecureValueValidator() secureValueClient := secret.ProvideSecureValueClient(secureValueService, secureValueValidator, accessClient) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer) @@ -1453,7 +1453,39 @@ func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { if err != nil { return Runner{}, err } - runner := NewRunner(cfg, sqlStore, ossImpl, serviceService, featureToggles, secretsService, secretsMigrator, userService) + tracer := otelTracer() + databaseDatabase := database5.ProvideDatabase(sqlStore, tracer) + registerer := metrics.ProvideRegisterer() + globalDataKeyStorage, err := encryption.ProvideGlobalDataKeyStorage(databaseDatabase, tracer, registerer) + if err != nil { + return Runner{}, err + } + encryptedValueStorage, err := encryption.ProvideEncryptedValueStorage(databaseDatabase, tracer) + if err != nil { + return Runner{}, err + } + globalEncryptedValueStorage, err := encryption.ProvideGlobalEncryptedValueStorage(databaseDatabase, tracer) + if err != nil { + return Runner{}, err + } + dataKeyStorage, err := encryption.ProvideDataKeyStorage(databaseDatabase, tracer, registerer) + if err != nil { + return Runner{}, err + } + cipher, err := service11.ProvideAESGCMCipherService(tracer, usageStats) + if err != nil { + return Runner{}, err + } + providerConfig, err := kmsproviders.ProvideOSSKMSProviders(cfg, cipher) + if err != nil { + return Runner{}, err + } + encryptionManager, err := manager4.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) + if err != nil { + return Runner{}, err + } + consolidationService := service12.ProvideConsolidationService(tracer, globalDataKeyStorage, encryptedValueStorage, globalEncryptedValueStorage, encryptionManager) + runner := NewRunner(cfg, sqlStore, ossImpl, serviceService, featureToggles, secretsService, secretsMigrator, userService, consolidationService) return runner, nil } @@ -1540,7 +1572,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator2.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service10.ProvideService, wire.Bind(new(service10.LDAP), new(*service10.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service7.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service7.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets2.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets2.Store), new(*database.SecretsStoreImpl)), grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database4.DashboardSnapshotStore)), database4.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service8.ServiceImpl)), service8.ProvideService, service7.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service7.Service)), service7.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager2.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), mtdsclient.NewNullMTDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service5.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service5.DashboardServiceImpl)), service5.ProvideDashboardService, service5.ProvideDashboardProvisioningService, service5.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), folderimpl.ProvideDashboardFolderStore, wire.Bind(new(folder.FolderStore), new(*folderimpl.DashboardFolderStoreImpl)), service9.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service9.ImportDashboardService)), service6.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service6.Service)), service6.ProvideDashboardUpdater, sanitizer.ProvideService, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, decrypt.ProvideDecryptService, encryption.ProvideDataKeyStorage, encryption.ProvideEncryptedValueStorage, service12.ProvideSecureValueService, validator3.ProvideKeeperValidator, validator3.ProvideSecureValueValidator, migrator2.NewWithEngine, database5.ProvideDatabase, wire.Bind(new(contracts.Database), new(*database5.Database)), manager4.ProvideEncryptionManager, service11.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator2.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service10.ProvideService, wire.Bind(new(service10.LDAP), new(*service10.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service7.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service7.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets2.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets2.Store), new(*database.SecretsStoreImpl)), grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database4.DashboardSnapshotStore)), database4.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service8.ServiceImpl)), service8.ProvideService, service7.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service7.Service)), service7.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager2.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), mtdsclient.NewNullMTDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service5.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service5.DashboardServiceImpl)), service5.ProvideDashboardService, service5.ProvideDashboardProvisioningService, service5.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), folderimpl.ProvideDashboardFolderStore, wire.Bind(new(folder.FolderStore), new(*folderimpl.DashboardFolderStoreImpl)), service9.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service9.ImportDashboardService)), service6.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service6.Service)), service6.ProvideDashboardUpdater, sanitizer.ProvideService, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, decrypt.ProvideDecryptService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, service12.ProvideSecureValueService, validator3.ProvideKeeperValidator, validator3.ProvideSecureValueValidator, migrator2.NewWithEngine, database5.ProvideDatabase, wire.Bind(new(contracts.Database), new(*database5.Database)), manager4.ProvideEncryptionManager, service11.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index bb715ed7dea..61a05716b47 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" gsmKMSProviders "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/kmsproviders" "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper" + secretService "github.com/grafana/grafana/pkg/registry/apis/secret/service" "github.com/grafana/grafana/pkg/registry/backgroundsvcs" "github.com/grafana/grafana/pkg/registry/usagestatssvcs" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -108,6 +109,7 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(kmsproviders.Service), new(osskmsproviders.Service)), secretkeeper.ProvideService, wire.Bind(new(contracts.KeeperService), new(*secretkeeper.OSSKeeperService)), + secretService.ProvideConsolidationService, ldap.ProvideGroupsService, wire.Bind(new(ldap.Groups), new(*ldap.OSSGroups)), guardian.ProvideGuardian, diff --git a/pkg/services/anonymous/anonimpl/api/api.go b/pkg/services/anonymous/anonimpl/api/api.go index 95774e1682b..b7e4e091ed2 100644 --- a/pkg/services/anonymous/anonimpl/api/api.go +++ b/pkg/services/anonymous/anonimpl/api/api.go @@ -55,7 +55,7 @@ func (api *AnonDeviceServiceAPI) RegisterAPIEndpoints() { }) } -// swagger:route GET /stats devices listDevices +// swagger:route GET /anonymous/devices devices listDevices // // # Lists all devices within the last 30 days // @@ -91,7 +91,7 @@ func (api *AnonDeviceServiceAPI) ListDevices(c *contextmodel.ReqContext) respons return response.JSON(http.StatusOK, resDevices) } -// swagger:route POST /search devices SearchDevices +// swagger:route GET /anonymous/search devices SearchDevices // // # Lists all devices within the last 30 days // diff --git a/pkg/services/apiserver/appinstaller/installer.go b/pkg/services/apiserver/appinstaller/installer.go index d80e865b5f0..9817f7513bc 100644 --- a/pkg/services/apiserver/appinstaller/installer.go +++ b/pkg/services/apiserver/appinstaller/installer.go @@ -8,17 +8,19 @@ import ( appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/logging" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" - "github.com/grafana/grafana/pkg/services/apiserver/builder" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/generic" genericapiserver "k8s.io/apiserver/pkg/server" + serverstore "k8s.io/apiserver/pkg/server/storage" "k8s.io/kube-openapi/pkg/common" + + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options" ) type LegacyStorageGetterFunc func(schema.GroupVersionResource) grafanarest.Storage @@ -31,13 +33,6 @@ type AuthorizerProvider interface { GetAuthorizer() authorizer.Authorizer } -type APIEnablementProvider interface { - // Do not implement this unless you have special circumstances! This is a list of resources that are allowed to be accessed in v0alpha1, - // to prevent accidental exposure of experimental APIs. While developing, use the feature flag `grafanaAPIServerWithExperimentalAPIs`. - // And then, when you're ready to expose this to the end user, go to v1beta1 instead. - GetAllowedV0Alpha1Resources() []string -} - type AppInstallerConfig struct { CustomConfig any AllowedV0Alpha1Resources []string @@ -132,9 +127,9 @@ func InstallAPIs( dualWriteService dualwrite.Service, dualWriterMetrics *grafanarest.DualWriterMetrics, builderMetrics *builder.BuilderMetrics, + apiResourceConfig *serverstore.ResourceConfig, ) error { logger := logging.FromContext(ctx) - for _, installer := range appInstallers { logger.Debug("Installing APIs for app installer", "app", installer.ManifestData().AppName) wrapper := &serverWrapper{ @@ -149,6 +144,7 @@ func InstallAPIs( dualWriteService: dualWriteService, dualWriterMetrics: dualWriterMetrics, builderMetrics: builderMetrics, + apiResourceConfig: apiResourceConfig, } if err := installer.InstallAPIs(wrapper, restOpsGetter); err != nil { return fmt.Errorf("failed to install APIs for app %s: %w", installer.ManifestData().AppName, err) diff --git a/pkg/services/apiserver/appinstaller/resourceconfig.go b/pkg/services/apiserver/appinstaller/resourceconfig.go new file mode 100644 index 00000000000..c2e45eb74e9 --- /dev/null +++ b/pkg/services/apiserver/appinstaller/resourceconfig.go @@ -0,0 +1,32 @@ +package appinstaller + +import ( + appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "k8s.io/apimachinery/pkg/runtime/schema" + serverstorage "k8s.io/apiserver/pkg/server/storage" +) + +func NewAPIResourceConfig(installers []appsdkapiserver.AppInstaller) *serverstorage.ResourceConfig { + ret := serverstorage.NewResourceConfig() + enable := []schema.GroupVersion{} + disable := []schema.GroupVersion{} + + for _, installer := range installers { + for _, version := range installer.ManifestData().Versions { + gv := schema.GroupVersion{ + Group: installer.ManifestData().Group, + Version: version.Name, + } + if version.Served { + enable = append(enable, gv) + } else { + disable = append(disable, gv) + } + } + } + + ret.EnableVersions(enable...) + ret.DisableVersions(disable...) + + return ret +} diff --git a/pkg/services/apiserver/appinstaller/server.go b/pkg/services/apiserver/appinstaller/server.go index 723c0753605..20792eeab0d 100644 --- a/pkg/services/apiserver/appinstaller/server.go +++ b/pkg/services/apiserver/appinstaller/server.go @@ -8,7 +8,9 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/registry/generic" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + genericrest "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" + serverstorage "k8s.io/apiserver/pkg/server/storage" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/logging" @@ -34,21 +36,14 @@ type serverWrapper struct { dualWriteService dualwrite.Service dualWriterMetrics *grafanarest.DualWriterMetrics builderMetrics *builder.BuilderMetrics + apiResourceConfig *serverstorage.ResourceConfig } func (s *serverWrapper) InstallAPIGroup(apiGroupInfo *genericapiserver.APIGroupInfo) error { log := logging.FromContext(s.ctx) - legacyProvider, ok := s.installer.(LegacyStorageProvider) - if !ok { - return s.GenericAPIServer.InstallAPIGroup(apiGroupInfo) - } for v, storageMap := range apiGroupInfo.VersionedResourcesStorageMap { for storagePath, restStorage := range storageMap { - genericStorage, ok := restStorage.(*genericregistry.Store) - if !ok { - log.Error("Expected generic registry store", "storagePath", storagePath, "version", v) - continue - } + legacyProvider, dualWriteSupported := s.installer.(LegacyStorageProvider) resource, err := getResourceFromStoragePath(storagePath) if err != nil { return err @@ -57,29 +52,34 @@ func (s *serverWrapper) InstallAPIGroup(apiGroupInfo *genericapiserver.APIGroupI Group: s.installer.ManifestData().Group, Resource: resource, } - genericStorage.KeyRootFunc = grafanaregistry.KeyRootFunc(gr) - genericStorage.KeyFunc = grafanaregistry.NamespaceKeyFunc(gr) - genericStorage.UpdateStrategy = &updateStrategyWrapper{ - RESTUpdateStrategy: genericStorage.UpdateStrategy, + gvr := gr.WithVersion(v) + if s.apiResourceConfig != nil && !s.apiResourceConfig.ResourceEnabled(gvr) { + log.Debug("Skipping storage for disabled resource", "gvr", gvr.String(), "storagePath", storagePath) + delete(apiGroupInfo.VersionedResourcesStorageMap[v], storagePath) + continue } - - dw, err := NewDualWriter( - s.ctx, - gr, - s.storageOpts, - legacyProvider.GetLegacyStorage(gr.WithVersion(v)), - grafanarest.Storage(genericStorage), - s.kvStore, - s.lock, - s.namespaceMapper, - s.dualWriteService, - s.dualWriterMetrics, - s.builderMetrics, - ) - if err != nil { - return err + storage := s.configureStorage(gr, dualWriteSupported, restStorage) + if unifiedStorage, ok := storage.(grafanarest.Storage); ok && dualWriteSupported { + log.Debug("Configuring dual writer for storage", "resource", gr.String(), "version", v, "storagePath", storagePath) + dw, err := NewDualWriter( + s.ctx, + gr, + s.storageOpts, + legacyProvider.GetLegacyStorage(gr.WithVersion(v)), + unifiedStorage, + s.kvStore, + s.lock, + s.namespaceMapper, + s.dualWriteService, + s.dualWriterMetrics, + s.builderMetrics, + ) + if err != nil { + return err + } + storage = dw } - apiGroupInfo.VersionedResourcesStorageMap[v][storagePath] = dw + apiGroupInfo.VersionedResourcesStorageMap[v][storagePath] = storage } } @@ -93,3 +93,27 @@ func getResourceFromStoragePath(storagePath string) (string, error) { } return parts[0], nil } + +func (s *serverWrapper) configureStorage(gr schema.GroupResource, dualWriteSupported bool, storage genericrest.Storage) genericrest.Storage { + if gs, ok := storage.(*genericregistry.Store); ok { + // if dual write is supported, we need to modify the update strategy + // this is not needed for the status store + if dualWriteSupported { + gs.UpdateStrategy = &updateStrategyWrapper{ + RESTUpdateStrategy: gs.UpdateStrategy, + } + } + gs.KeyFunc = grafanaregistry.NamespaceKeyFunc(gr) + gs.KeyRootFunc = grafanaregistry.KeyRootFunc(gr) + return gs + } + + // if the storage is a status store, we need to extract the underlying generic registry store + if statusStore, ok := storage.(*appsdkapiserver.StatusREST); ok { + statusStore.Store.KeyFunc = grafanaregistry.NamespaceKeyFunc(gr) + statusStore.Store.KeyRootFunc = grafanaregistry.KeyRootFunc(gr) + return statusStore + } + + return storage +} diff --git a/pkg/services/apiserver/builder/openapi.go b/pkg/services/apiserver/builder/openapi.go index 9489ac38b95..cde3548a136 100644 --- a/pkg/services/apiserver/builder/openapi.go +++ b/pkg/services/apiserver/builder/openapi.go @@ -1,21 +1,42 @@ package builder import ( + "bytes" + "encoding/json" "maps" "strings" + "sync" + apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/runtime/schema" openapi "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" spec "k8s.io/kube-openapi/pkg/validation/spec" + "github.com/grafana/grafana-app-sdk/logging" data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" secret "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" ) +var ( + equalityInit sync.Once +) + // This should eventually live in grafana-app-sdk func GetOpenAPIDefinitions(builders []APIGroupBuilder, additionalGetters ...openapi.GetOpenAPIDefinitions) openapi.GetOpenAPIDefinitions { + equalityInit.Do(func() { + // DataQuery has private variables, so it needs an explicit equality helper + err := apiequality.Semantic.AddFunc( + func(a, b data.DataQuery) bool { + aa, _ := json.Marshal(a) + bb, _ := json.Marshal(b) + return bytes.Equal(aa, bb) + }, + ) + logging.DefaultLogger.Error("error initializing DataQuery apiequality", "err", err) + }) + return func(ref openapi.ReferenceCallback) map[string]openapi.OpenAPIDefinition { defs := common.GetOpenAPIDefinitions(ref) // common grafana apis maps.Copy(defs, data.GetOpenAPIDefinitions(ref)) diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 8cf5789c777..dc2234e6e56 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -39,6 +39,13 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") + runtimeConfig := apiserverCfg.Key("runtime_config").String() + if runtimeConfig != "" { + if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil { + return fmt.Errorf("failed to set runtime config: %w", err) + } + } + o.RecommendedOptions.Etcd.StorageConfig.Transport.ServerList = apiserverCfg.Key("etcd_servers").Strings(",") o.RecommendedOptions.SecureServing.BindAddress = ip diff --git a/pkg/services/apiserver/options/options.go b/pkg/services/apiserver/options/options.go index a9209cdbc00..721f1ce570b 100644 --- a/pkg/services/apiserver/options/options.go +++ b/pkg/services/apiserver/options/options.go @@ -21,6 +21,7 @@ const defaultEtcdPathPrefix = "/registry/grafana.app" type Options struct { RecommendedOptions *genericoptions.RecommendedOptions + APIEnablementOptions *genericoptions.APIEnablementOptions GrafanaAggregatorOptions *GrafanaAggregatorOptions StorageOptions *StorageOptions ExtraOptions *ExtraOptions @@ -30,6 +31,7 @@ type Options struct { func NewOptions(codec runtime.Codec) *Options { return &Options{ RecommendedOptions: NewRecommendedOptions(codec), + APIEnablementOptions: genericoptions.NewAPIEnablementOptions(), GrafanaAggregatorOptions: NewGrafanaAggregatorOptions(), StorageOptions: NewStorageOptions(), ExtraOptions: NewExtraOptions(), @@ -38,6 +40,7 @@ func NewOptions(codec runtime.Codec) *Options { func (o *Options) AddFlags(fs *pflag.FlagSet) { o.RecommendedOptions.AddFlags(fs) + o.APIEnablementOptions.AddFlags(fs) o.GrafanaAggregatorOptions.AddFlags(fs) o.StorageOptions.AddFlags(fs) o.ExtraOptions.AddFlags(fs) diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 58bf5ad4ac7..be580025823 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -304,11 +304,19 @@ func (s *service) start(ctx context.Context) error { return errs[0] } + if errs := o.APIEnablementOptions.Validate(s.scheme); len(errs) != 0 { + return errs[0] + } + serverConfig := genericapiserver.NewRecommendedConfig(s.codecs) if err := o.ApplyTo(serverConfig); err != nil { return err } + if err := o.APIEnablementOptions.ApplyTo(&serverConfig.Config, appinstaller.NewAPIResourceConfig(s.appInstallers), s.scheme); err != nil { + return err + } + serverConfig.Authorization.Authorizer = s.authorizer serverConfig.Authentication.Authenticator = authenticator.NewAuthenticator(serverConfig.Authentication.Authenticator) serverConfig.TracerProvider = s.tracing.GetTracerProvider() @@ -395,6 +403,7 @@ func (s *service) start(ctx context.Context) error { s.storageStatus, s.dualWriterMetrics, s.builderMetrics, + serverConfig.MergedResourceConfig, ); err != nil { return err } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 043438db828..1eb611fa670 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -474,40 +474,36 @@ func (dr *DashboardServiceImpl) Count(ctx context.Context, scopeParams *quota.Sc } func (dr *DashboardServiceImpl) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*dashboards.DashboardRef, error) { - if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesLibraryPanelConnections) { - res, err := dr.k8sclient.Search(ctx, orgID, &resourcepb.ResourceSearchRequest{ - Options: &resourcepb.ListOptions{ - Fields: []*resourcepb.Requirement{ - { - Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE, - Operator: string(selection.Equals), - Values: []string{libraryPanelUID}, - }, + res, err := dr.k8sclient.Search(ctx, orgID, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Fields: []*resourcepb.Requirement{ + { + Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE, + Operator: string(selection.Equals), + Values: []string{libraryPanelUID}, }, }, - Limit: listAllDashboardsLimit, - }) - if err != nil { - return nil, err - } - - results, err := dashboardsearch.ParseResults(res, 0) - if err != nil { - return nil, err - } - - dashes := make([]*dashboards.DashboardRef, 0, len(results.Hits)) - for _, row := range results.Hits { - dashes = append(dashes, &dashboards.DashboardRef{ - UID: row.Name, - FolderUID: row.Folder, - ID: row.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID), // nolint:staticcheck - }) - } - return dashes, nil + }, + Limit: listAllDashboardsLimit, + }) + if err != nil { + return nil, err } - return dr.dashboardStore.GetDashboardsByLibraryPanelUID(ctx, libraryPanelUID, orgID) + results, err := dashboardsearch.ParseResults(res, 0) + if err != nil { + return nil, err + } + + dashes := make([]*dashboards.DashboardRef, 0, len(results.Hits)) + for _, row := range results.Hits { + dashes = append(dashes, &dashboards.DashboardRef{ + UID: row.Name, + FolderUID: row.Folder, + ID: row.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID), // nolint:staticcheck + }) + } + return dashes, nil } func (dr *DashboardServiceImpl) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 95e5a0a9023..1cda81c5288 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -2493,7 +2493,7 @@ func TestGetDashboardsByLibraryPanelUID(t *testing.T) { dashboardStore: &fakeStore, folderService: folderSvc, ac: actest.FakeAccessControl{ExpectedEvaluate: true}, - features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesLibraryPanelConnections), + features: featuremgmt.WithFeatures(), publicDashboardService: fakePublicDashboardService, k8sclient: k8sCliMock, } diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 9185887aec9..74259652fbf 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -90,7 +90,6 @@ func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64, isFol return r0, r1 } - // DeleteDashboard provides a mock function with given fields: ctx, cmd func (_m *FakeDashboardStore) DeleteDashboard(ctx context.Context, cmd *DeleteDashboardCommand) error { ret := _m.Called(ctx, cmd) @@ -127,7 +126,6 @@ func (_m *FakeDashboardStore) DeleteDashboardsInFolders(ctx context.Context, req return r0 } - // FindDashboards provides a mock function with given fields: ctx, query func (_m *FakeDashboardStore) FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { ret := _m.Called(ctx, query) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 0ac3c4d9ce2..7b74ad685e8 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -469,13 +469,6 @@ var ( Owner: grafanaAppPlatformSquad, RequiresRestart: true, // changes the API routing }, - { - Name: "kubernetesLibraryPanelConnections", - Description: "Routes library panel connections requests from /api to using search", - Stage: FeatureStageExperimental, - Owner: grafanaAppPlatformSquad, - RequiresRestart: true, // changes the API routing - }, { Name: "kubernetesDashboards", Description: "Use the kubernetes API in the frontend for dashboards", @@ -501,6 +494,12 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaAppPlatformSquad, }, + { + Name: "scanRowInvalidDashboardParseFallbackEnabled", + Description: "Enable fallback parsing behavior when scan row encounters invalid dashboard JSON", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + }, { Name: "datasourceQueryTypes", Description: "Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus)", @@ -770,6 +769,16 @@ var ( HideFromAdminPage: true, Expression: "false", }, + { + Name: "useScopeSingleNodeEndpoint", + Description: "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + Expression: "false", + FrontendOnly: true, + HideFromDocs: true, + HideFromAdminPage: true, + }, { Name: "promQLScope", Description: "In-development feature that will allow injection of labels into prometheus queries.", @@ -1858,6 +1867,13 @@ var ( Owner: grafanaDataProSquad, FrontendOnly: true, }, + { + Name: "dashboardLevelTimeMacros", + Description: "Supports __from and __to macros that always use the dashboard level time range", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + FrontendOnly: true, + }, { Name: "alertmanagerRemoteSecondaryWithRemoteState", Description: "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", @@ -1874,6 +1890,13 @@ var ( Owner: grafanaDataProSquad, FrontendOnly: true, }, + { + Name: "newLogContext", + Description: "New Log Context component", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityLogsSquad, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 7b5c27e5123..6e5c55cfd35 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -60,11 +60,11 @@ disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad formatString,GA,@grafana/dataviz-squad,false,false,true kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesLibraryPanels,experimental,@grafana/grafana-app-platform-squad,false,true,false -kubernetesLibraryPanelConnections,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true dashboardDisableSchemaValidationV1,experimental,@grafana/grafana-app-platform-squad,false,false,false dashboardDisableSchemaValidationV2,experimental,@grafana/grafana-app-platform-squad,false,false,false dashboardSchemaValidationLogging,experimental,@grafana/grafana-app-platform-squad,false,false,false +scanRowInvalidDashboardParseFallbackEnabled,experimental,@grafana/search-and-storage,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-datasources-core-services,false,true,false queryServiceRewrite,experimental,@grafana/grafana-datasources-core-services,false,true,false @@ -101,6 +101,7 @@ secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-s alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false +useScopeSingleNodeEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true promQLScope,GA,@grafana/oss-big-tent,false,false,false logQLScope,privatePreview,@grafana/observability-logs,false,false,false sqlExpressions,privatePreview,@grafana/grafana-datasources-core-services,false,false,false @@ -240,5 +241,7 @@ alertingNotificationHistory,experimental,@grafana/alerting-squad,false,false,fal pluginAssetProvider,experimental,@grafana/plugins-platform-backend,false,true,false unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true +dashboardLevelTimeMacros,experimental,@grafana/dashboards-squad,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true +newLogContext,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index b1cc2fb7f13..0b00cf45ac1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -251,10 +251,6 @@ const ( // Routes library panel requests from /api to the /apis endpoint FlagKubernetesLibraryPanels = "kubernetesLibraryPanels" - // FlagKubernetesLibraryPanelConnections - // Routes library panel connections requests from /api to using search - FlagKubernetesLibraryPanelConnections = "kubernetesLibraryPanelConnections" - // FlagKubernetesDashboards // Use the kubernetes API in the frontend for dashboards FlagKubernetesDashboards = "kubernetesDashboards" @@ -271,6 +267,10 @@ const ( // Log schema validation errors so they can be analyzed later FlagDashboardSchemaValidationLogging = "dashboardSchemaValidationLogging" + // FlagScanRowInvalidDashboardParseFallbackEnabled + // Enable fallback parsing behavior when scan row encounters invalid dashboard JSON + FlagScanRowInvalidDashboardParseFallbackEnabled = "scanRowInvalidDashboardParseFallbackEnabled" + // FlagDatasourceQueryTypes // Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) FlagDatasourceQueryTypes = "datasourceQueryTypes" @@ -415,6 +415,10 @@ const ( // In-development feature flag for the scope api using the app platform. FlagScopeApi = "scopeApi" + // FlagUseScopeSingleNodeEndpoint + // Use the single node endpoint for the scope api. This is used to fetch the scope parent node. + FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint" + // FlagPromQLScope // In-development feature that will allow injection of labels into prometheus queries. FlagPromQLScope = "promQLScope" @@ -971,6 +975,10 @@ const ( // Enables adhoc filtering support for the dashboard datasource FlagDashboardDsAdHocFiltering = "dashboardDsAdHocFiltering" + // FlagDashboardLevelTimeMacros + // Supports __from and __to macros that always use the dashboard level time range + FlagDashboardLevelTimeMacros = "dashboardLevelTimeMacros" + // FlagAlertmanagerRemoteSecondaryWithRemoteState // Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState" @@ -978,4 +986,8 @@ const ( // FlagAdhocFiltersInTooltips // Enable adhoc filter buttons in visualization tooltips FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" + + // FlagNewLogContext + // New Log Context component + FlagNewLogContext = "newLogContext" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 048f5512581..e47603d79d4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -836,6 +836,19 @@ "frontend": true } }, + { + "metadata": { + "name": "dashboardLevelTimeMacros", + "resourceVersion": "1753435849295", + "creationTimestamp": "2025-07-25T09:30:49Z" + }, + "spec": { + "description": "Supports __from and __to macros that always use the dashboard level time range", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "dashboardNewLayouts", @@ -1756,19 +1769,6 @@ "hideFromAdminPage": true } }, - { - "metadata": { - "name": "kubernetesLibraryPanelConnections", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-07-25T13:06:00Z" - }, - "spec": { - "description": "Routes library panel connections requests from /api to using search", - "stage": "experimental", - "codeowner": "@grafana/grafana-app-platform-squad", - "requiresRestart": true - } - }, { "metadata": { "name": "kubernetesLibraryPanels", @@ -2150,6 +2150,19 @@ "expression": "false" } }, + { + "metadata": { + "name": "newLogContext", + "resourceVersion": "1754044501326", + "creationTimestamp": "2025-08-01T10:35:01Z" + }, + "spec": { + "description": "New Log Context component", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, { "metadata": { "name": "newLogsPanel", @@ -2760,6 +2773,18 @@ "codeowner": "@grafana/identity-access-team" } }, + { + "metadata": { + "name": "scanRowInvalidDashboardParseFallbackEnabled", + "resourceVersion": "1753730899886", + "creationTimestamp": "2025-07-28T19:28:19Z" + }, + "spec": { + "description": "Enable fallback parsing behavior when scan row encounters invalid dashboard JSON", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage" + } + }, { "metadata": { "name": "scopeApi", @@ -3230,6 +3255,22 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "useScopeSingleNodeEndpoint", + "resourceVersion": "1753960766702", + "creationTimestamp": "2025-07-31T11:19:26Z" + }, + "spec": { + "description": "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "useScopesNavigationEndpoint", diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 2ece6114fa5..f582df4604e 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -86,7 +86,7 @@ .fs-hidden { display: none; } - + .fs-spinner { animation: spin 1500ms linear infinite; width: 32px; @@ -100,7 +100,7 @@ .fs-spinner-arc { stroke: #F55F3E; } - + .fs-loader-text { opacity: 0; font-size: 16px; @@ -199,7 +199,7 @@ async function fetchBootData() { const resp = await fetch("/bootdata"); const textResponse = await resp.text(); - + let rawBootData; try { rawBootData = JSON.parse(textResponse); @@ -211,7 +211,7 @@ if (resp.status === 503 && rawBootData.code === 'Loading') { return; } - + if (!resp.ok) { throw new Error("Unexpected response body: " + textResponse); } @@ -259,21 +259,20 @@ const cssLink = document.createElement("link"); cssLink.rel = 'stylesheet'; - let theme = window.grafanaBootData.user.theme; + const theme = window.grafanaBootData.user.theme; if (theme === "system") { const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); - theme = darkQuery.matches ? 'dark' : 'light'; - } - if (theme === "light") { - document.body.classList.add("theme-light"); - cssLink.href = window.grafanaBootData.assets.light; - window.grafanaBootData.user.lightTheme = true; - } else if (theme === "dark") { - document.body.classList.add("theme-dark"); - cssLink.href = window.grafanaBootData.assets.dark; - window.grafanaBootData.user.lightTheme = false; + if (darkQuery.matches) { + document.body.classList.add("theme-dark"); + window.grafanaBootData.user.lightTheme = false; + } else { + document.body.classList.add("theme-light"); + window.grafanaBootData.user.lightTheme = true; + } } + const isLightTheme = window.grafanaBootData.user.lightTheme; + cssLink.href = window.grafanaBootData.assets[isLightTheme ? 'light' : 'dark']; document.head.appendChild(cssLink); } diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 5514705ed9a..250b3bf8717 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -17,6 +17,7 @@ const ( WeightDashboard WeightExplore WeightDrilldown + WeightAssistant WeightAlerting WeightAlertsAndIncidents WeightAIAndML diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 42770b6ee96..b4571c88a69 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -324,7 +324,8 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-irm-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "IRM"}, "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 4, Text: "OnCall"}, "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 5, Text: "Incident"}, - "grafana-ml-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAIAndML, Text: "AI & machine learning", SubTitle: "Explore AI and machine learning features", Icon: "gf-ml-alt"}, + "grafana-assistant-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAssistant, Text: "Assistant", SubTitle: "AI-powered assistant for Grafana", Icon: "ai-sparkle", IsNew: true}, + "grafana-ml-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAIAndML, Text: "Machine Learning", SubTitle: "Explore AI and machine learning features", Icon: "gf-ml-alt"}, "grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 7}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfgPlugins, SortWeight: 3}, "grafana-costmanagementui-app": {SectionID: navtree.NavIDCfg, Text: "Cost management"}, diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index bf2a859fd61..7eae1a55974 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -5523,6 +5523,586 @@ "version": "1.1.0" }, "paths": { + "/convert/api/prom/rules": { + "get": { + "operationId": "RouteConvertPrometheusCortexGetRules", + "produces": [ + "application/yaml" + ], + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.", + "tags": [ + "convert_prometheus" + ] + }, + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "operationId": "RouteConvertPrometheusCortexPostRuleGroups", + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "tags": [ + "convert_prometheus" + ] + } + }, + "/convert/api/prom/rules/{NamespaceTitle}": { + "delete": { + "operationId": "RouteConvertPrometheusCortexDeleteNamespace", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.", + "tags": [ + "convert_prometheus" + ] + }, + "get": { + "operationId": "RouteConvertPrometheusCortexGetNamespace", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/yaml" + ], + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).", + "tags": [ + "convert_prometheus" + ] + }, + "post": { + "consumes": [ + "application/yaml" + ], + "description": "If the group already exists and was not imported from a Prometheus-compatible source initially,\nit will not be replaced and an error will be returned.", + "operationId": "RouteConvertPrometheusCortexPostRuleGroup", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-datasource-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-recording-rules-paused", + "type": "boolean" + }, + { + "in": "header", + "name": "x-grafana-alerting-alert-rules-paused", + "type": "boolean" + }, + { + "in": "header", + "name": "x-grafana-alerting-target-datasource-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-folder-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-notification-receiver", + "type": "string" + }, + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.", + "tags": [ + "convert_prometheus" + ], + "x-raw-request": "true" + } + }, + "/convert/api/prom/rules/{NamespaceTitle}/{Group}": { + "delete": { + "operationId": "RouteConvertPrometheusCortexDeleteRuleGroup", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.", + "tags": [ + "convert_prometheus" + ] + }, + "get": { + "operationId": "RouteConvertPrometheusCortexGetRuleGroup", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/yaml" + ], + "responses": { + "200": { + "description": "PrometheusRuleGroup", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.", + "tags": [ + "convert_prometheus" + ] + } + }, + "/convert/prometheus/config/v1/rules": { + "get": { + "operationId": "RouteConvertPrometheusGetRules", + "produces": [ + "application/yaml" + ], + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.", + "tags": [ + "convert_prometheus" + ] + }, + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "operationId": "RouteConvertPrometheusPostRuleGroups", + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "tags": [ + "convert_prometheus" + ] + } + }, + "/convert/prometheus/config/v1/rules/{NamespaceTitle}": { + "delete": { + "operationId": "RouteConvertPrometheusDeleteNamespace", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.", + "tags": [ + "convert_prometheus" + ] + }, + "get": { + "operationId": "RouteConvertPrometheusGetNamespace", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/yaml" + ], + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).", + "tags": [ + "convert_prometheus" + ] + }, + "post": { + "consumes": [ + "application/yaml" + ], + "description": "If the group already exists and was not imported from a Prometheus-compatible source initially,\nit will not be replaced and an error will be returned.", + "operationId": "RouteConvertPrometheusPostRuleGroup", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-datasource-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-recording-rules-paused", + "type": "boolean" + }, + { + "in": "header", + "name": "x-grafana-alerting-alert-rules-paused", + "type": "boolean" + }, + { + "in": "header", + "name": "x-grafana-alerting-target-datasource-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-folder-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-notification-receiver", + "type": "string" + }, + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.", + "tags": [ + "convert_prometheus" + ], + "x-raw-request": "true" + } + }, + "/convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group}": { + "delete": { + "operationId": "RouteConvertPrometheusDeleteRuleGroup", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.", + "tags": [ + "convert_prometheus" + ] + }, + "get": { + "operationId": "RouteConvertPrometheusGetRuleGroup", + "parameters": [ + { + "in": "path", + "name": "NamespaceTitle", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/yaml" + ], + "responses": { + "200": { + "description": "PrometheusRuleGroup", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.", + "tags": [ + "convert_prometheus" + ] + } + }, "/v1/provisioning/alert-rules": { "get": { "operationId": "RouteGetAlertRules", diff --git a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go index 98b17d19ed3..b3c2db6fe7a 100644 --- a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go +++ b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go @@ -5,7 +5,7 @@ import ( ) // Route for mimirtool -// swagger:route GET /convert/prometheus/config/v1/rules convert_prometheus RouteConvertPrometheusGetRules +// swagger:route GET /convert/prometheus/config/v1/rules convert_prometheus stable RouteConvertPrometheusGetRules // // Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace. // @@ -18,7 +18,7 @@ import ( // 404: NotFound // Route for cortextool -// swagger:route GET /convert/api/prom/rules convert_prometheus RouteConvertPrometheusCortexGetRules +// swagger:route GET /convert/api/prom/rules convert_prometheus stable RouteConvertPrometheusCortexGetRules // // Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace. // @@ -31,7 +31,7 @@ import ( // 404: NotFound // Route for mimirtool -// swagger:route GET /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusGetNamespace +// swagger:route GET /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus stable RouteConvertPrometheusGetNamespace // // Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder). // @@ -44,7 +44,7 @@ import ( // 404: NotFound // Route for cortextool -// swagger:route GET /convert/api/prom/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusCortexGetNamespace +// swagger:route GET /convert/api/prom/rules/{NamespaceTitle} convert_prometheus stable RouteConvertPrometheusCortexGetNamespace // // Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder). // @@ -57,7 +57,7 @@ import ( // 404: NotFound // Route for mimirtool -// swagger:route GET /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} convert_prometheus RouteConvertPrometheusGetRuleGroup +// swagger:route GET /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} convert_prometheus stable RouteConvertPrometheusGetRuleGroup // // Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source. // @@ -70,7 +70,7 @@ import ( // 404: NotFound // Route for cortextool -// swagger:route GET /convert/api/prom/rules/{NamespaceTitle}/{Group} convert_prometheus RouteConvertPrometheusCortexGetRuleGroup +// swagger:route GET /convert/api/prom/rules/{NamespaceTitle}/{Group} convert_prometheus stable RouteConvertPrometheusCortexGetRuleGroup // // Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source. // @@ -82,7 +82,7 @@ import ( // 403: ForbiddenError // 404: NotFound -// swagger:route POST /convert/prometheus/config/v1/rules convert_prometheus RouteConvertPrometheusPostRuleGroups +// swagger:route POST /convert/prometheus/config/v1/rules convert_prometheus stable RouteConvertPrometheusPostRuleGroups // // Converts the submitted rule groups into Grafana-Managed Rules. // @@ -97,7 +97,7 @@ import ( // 202: ConvertPrometheusResponse // 403: ForbiddenError -// swagger:route POST /convert/api/prom/rules convert_prometheus RouteConvertPrometheusCortexPostRuleGroups +// swagger:route POST /convert/api/prom/rules convert_prometheus stable RouteConvertPrometheusCortexPostRuleGroups // // Converts the submitted rule groups into Grafana-Managed Rules. // @@ -113,7 +113,7 @@ import ( // 403: ForbiddenError // Route for mimirtool -// swagger:route POST /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusPostRuleGroup +// swagger:route POST /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus stable RouteConvertPrometheusPostRuleGroup // // Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace. // If the group already exists and was not imported from a Prometheus-compatible source initially, @@ -133,7 +133,7 @@ import ( // x-raw-request: true // Route for cortextool -// swagger:route POST /convert/api/prom/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusCortexPostRuleGroup +// swagger:route POST /convert/api/prom/rules/{NamespaceTitle} convert_prometheus stable RouteConvertPrometheusCortexPostRuleGroup // // Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace. // If the group already exists and was not imported from a Prometheus-compatible source initially, @@ -153,7 +153,7 @@ import ( // x-raw-request: true // Route for mimirtool -// swagger:route DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusDeleteNamespace +// swagger:route DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus stable RouteConvertPrometheusDeleteNamespace // // Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace. // @@ -165,7 +165,7 @@ import ( // 403: ForbiddenError // Route for cortextool -// swagger:route DELETE /convert/api/prom/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusCortexDeleteNamespace +// swagger:route DELETE /convert/api/prom/rules/{NamespaceTitle} convert_prometheus stable RouteConvertPrometheusCortexDeleteNamespace // // Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace. // @@ -177,7 +177,7 @@ import ( // 403: ForbiddenError // Route for mimirtool -// swagger:route DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} convert_prometheus RouteConvertPrometheusDeleteRuleGroup +// swagger:route DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} convert_prometheus stable RouteConvertPrometheusDeleteRuleGroup // // Deletes a specific rule group if it was imported from a Prometheus-compatible source. // @@ -189,7 +189,7 @@ import ( // 403: ForbiddenError // Route for cortextool -// swagger:route DELETE /convert/api/prom/rules/{NamespaceTitle}/{Group} convert_prometheus RouteConvertPrometheusCortexDeleteRuleGroup +// swagger:route DELETE /convert/api/prom/rules/{NamespaceTitle}/{Group} convert_prometheus stable RouteConvertPrometheusCortexDeleteRuleGroup // // Deletes a specific rule group if it was imported from a Prometheus-compatible source. // diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 1b2744b976d..0617865b8d2 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1076,7 +1076,8 @@ "application/yaml" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.", "operationId": "RouteConvertPrometheusCortexGetRules", @@ -1110,7 +1111,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", "operationId": "RouteConvertPrometheusCortexPostRuleGroups", @@ -1136,7 +1138,8 @@ "application/yaml" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).", "operationId": "RouteConvertPrometheusCortexGetNamespace", @@ -1178,7 +1181,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.", "operationId": "RouteConvertPrometheusCortexPostRuleGroup", @@ -1248,7 +1252,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.", "operationId": "RouteConvertPrometheusCortexDeleteNamespace", @@ -1282,7 +1287,8 @@ "application/yaml" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.", "operationId": "RouteConvertPrometheusCortexGetRuleGroup", @@ -1326,7 +1332,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.", "operationId": "RouteConvertPrometheusCortexDeleteRuleGroup", @@ -1483,7 +1490,8 @@ "application/yaml" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.", "operationId": "RouteConvertPrometheusGetRules", @@ -1517,7 +1525,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", "operationId": "RouteConvertPrometheusPostRuleGroups", @@ -1543,7 +1552,8 @@ "application/yaml" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).", "operationId": "RouteConvertPrometheusGetNamespace", @@ -1585,7 +1595,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.", "operationId": "RouteConvertPrometheusPostRuleGroup", @@ -1655,7 +1666,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.", "operationId": "RouteConvertPrometheusDeleteNamespace", @@ -1689,7 +1701,8 @@ "application/yaml" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.", "operationId": "RouteConvertPrometheusGetRuleGroup", @@ -1733,7 +1746,8 @@ "application/json" ], "tags": [ - "convert_prometheus" + "convert_prometheus", + "stable" ], "summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.", "operationId": "RouteConvertPrometheusDeleteRuleGroup", diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 3861fa60c54..e794460fc5e 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -225,7 +225,6 @@ func (ng *AlertNG) init() error { if remotePrimary { ng.Log.Debug("Starting Grafana with remote primary mode enabled") m.Info.WithLabelValues(metrics.ModeRemotePrimary).Set(1) - ng.Cfg.UnifiedAlerting.SkipClustering = true // This function will be used by the MOA to create new Alertmanagers. override = notifier.WithAlertmanagerOverride(func(factoryFn notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory { return func(ctx context.Context, orgID int64) (notifier.Alertmanager, error) { diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 4ce738c08a9..4aa0151d18f 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -161,12 +161,8 @@ func NewMultiOrgAlertmanager( peer: &NilPeer{}, } - if cfg.UnifiedAlerting.SkipClustering { - l.Info("Skipping setting up clustering for MOA") - } else { - if err := moa.setupClustering(cfg); err != nil { - return nil, err - } + if err := moa.setupClustering(cfg); err != nil { + return nil, err } // Set up the default per tenant Alertmanager factory. diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 7c1e1dd4060..8329c7bb8de 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -2,10 +2,10 @@ package remote import ( "context" - "crypto/md5" "encoding/base64" "encoding/json" "fmt" + "hash/fnv" "net/http" "net/url" "strings" @@ -16,12 +16,12 @@ import ( "github.com/grafana/alerting/definition" alertingModels "github.com/grafana/alerting/models" alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/alerting/utils/hash" amalert "github.com/prometheus/alertmanager/api/v2/client/alert" amalertgroup "github.com/prometheus/alertmanager/api/v2/client/alertgroup" amgeneral "github.com/prometheus/alertmanager/api/v2/client/general" amsilence "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/client_golang/prometheus" - "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/log" @@ -73,6 +73,9 @@ type Alertmanager struct { amClient *remoteClient.Alertmanager mimirClient remoteClient.MimirClient + + promoteConfig bool + externalURL string } type AlertmanagerConfig struct { @@ -127,13 +130,10 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto logger := log.New("ngalert.remote.alertmanager") mcCfg := &remoteClient.Config{ - Logger: logger, - Password: cfg.BasicAuthPassword, - TenantID: cfg.TenantID, - URL: u, - PromoteConfig: cfg.PromoteConfig, - ExternalURL: cfg.ExternalURL, - Smtp: cfg.SmtpConfig, + Logger: logger, + Password: cfg.BasicAuthPassword, + TenantID: cfg.TenantID, + URL: u, } mc, err := remoteClient.New(mcCfg, metrics, tracer) if err != nil { @@ -188,7 +188,10 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto syncInterval: cfg.SyncInterval, tenantID: cfg.TenantID, url: cfg.URL, - smtp: cfg.SmtpConfig, + + externalURL: cfg.ExternalURL, + promoteConfig: cfg.PromoteConfig, + smtp: cfg.SmtpConfig, } // Parse the default configuration once and remember its hash so we can compare it later. @@ -196,15 +199,11 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto // (grouping, group timing, time intervals etc) changes the autogenerated configuration. // The `default` flag is sent to the remote Alertmanager for informational purposes, so we can tolerate this. err = func() error { - defaultCfg, err := am.buildConfiguration(ctx, []byte(cfg.DefaultConfig)) + defaultCfg, err := am.buildConfiguration(ctx, []byte(cfg.DefaultConfig), 0) if err != nil { return fmt.Errorf("unable to build default configuration: %w", err) } - rawDefaultCfg, err := json.Marshal(defaultCfg) - if err != nil { - return fmt.Errorf("unable to marshal default configuration: %w", err) - } - am.defaultConfigHash = fmt.Sprintf("%x", md5.Sum(rawDefaultCfg)) + am.defaultConfigHash = defaultCfg.Hash return nil }() if err != nil { @@ -265,22 +264,16 @@ func (am *Alertmanager) checkReadiness(ctx context.Context) error { // CompareAndSendConfiguration checks whether a given configuration is being used by the remote Alertmanager. // If not, it sends the configuration to the remote Alertmanager. func (am *Alertmanager) CompareAndSendConfiguration(ctx context.Context, config *models.AlertConfiguration) error { - payload, err := am.buildConfiguration(ctx, []byte(config.AlertmanagerConfiguration)) + payload, err := am.buildConfiguration(ctx, []byte(config.AlertmanagerConfiguration), config.CreatedAt) if err != nil { return fmt.Errorf("unable to build configuration: %w", err) } - rawPayload, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("unable to marshal decrypted configuration: %w", err) - } - configHash := fmt.Sprintf("%x", md5.Sum(rawPayload)) - // Send the configuration only if we need to. - if !am.shouldSendConfig(ctx, configHash) { + if !am.shouldSendConfig(ctx, payload.Hash) { return nil } - return am.sendConfiguration(ctx, payload, configHash, config.CreatedAt, am.isDefaultConfiguration(configHash)) + return am.sendConfiguration(ctx, payload) } func (am *Alertmanager) isDefaultConfiguration(configHash string) bool { @@ -303,31 +296,31 @@ func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn { // buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use. // It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs. -func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte) (remoteClient.GrafanaAlertmanagerConfig, error) { +func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64) (remoteClient.UserGrafanaConfig, error) { c, err := notifier.Load(raw) if err != nil { - return remoteClient.GrafanaAlertmanagerConfig{}, err + return remoteClient.UserGrafanaConfig{}, err } // Add auto-generated routes and decrypt before comparing. if err := am.autogenFn(ctx, am.log, am.orgID, &c.AlertmanagerConfig, true); err != nil { - return remoteClient.GrafanaAlertmanagerConfig{}, err + return remoteClient.UserGrafanaConfig{}, err } // Decrypt the receivers in the configuration. decryptedReceivers, err := legacy_storage.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto)) if err != nil { - return remoteClient.GrafanaAlertmanagerConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err) + return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err) } c.AlertmanagerConfig.Receivers = decryptedReceivers if err := am.crypto.DecryptExtraConfigs(ctx, c); err != nil { - return remoteClient.GrafanaAlertmanagerConfig{}, fmt.Errorf("unable to decrypt extra configs: %w", err) + return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt extra configs: %w", err) } mergeResult, err := c.GetMergedAlertmanagerConfig() if err != nil { - return remoteClient.GrafanaAlertmanagerConfig{}, fmt.Errorf("unable to get merged Alertmanager configuration: %w", err) + return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to get merged Alertmanager configuration: %w", err) } var templates []definition.PostableApiTemplate @@ -335,22 +328,31 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte) (rem templates = definition.TemplatesMapToPostableAPITemplates(c.ExtraConfigs[0].TemplateFiles, definition.MimirTemplateKind) } - return remoteClient.GrafanaAlertmanagerConfig{ - TemplateFiles: c.TemplateFiles, - AlertmanagerConfig: mergeResult.Config, - Templates: templates, - }, nil + payload := remoteClient.UserGrafanaConfig{ + GrafanaAlertmanagerConfig: remoteClient.GrafanaAlertmanagerConfig{ + TemplateFiles: c.TemplateFiles, + AlertmanagerConfig: mergeResult.Config, + Templates: templates, + }, + CreatedAt: createdAtEpoch, + Promoted: am.promoteConfig, + ExternalURL: am.externalURL, + SmtpConfig: am.smtp, + } + + cfgHash, err := calculateUserGrafanaConfigHash(payload) + if err != nil { + am.log.Error("Unable to calculate hash of the configuration. Using the empty string", "error", err) + cfgHash = "" + } + payload.Hash = cfgHash + payload.Default = am.isDefaultConfiguration(cfgHash) + return payload, nil } -func (am *Alertmanager) sendConfiguration(ctx context.Context, cfg remoteClient.GrafanaAlertmanagerConfig, hash string, createdAt int64, isDefault bool) error { +func (am *Alertmanager) sendConfiguration(ctx context.Context, cfg remoteClient.UserGrafanaConfig) error { am.metrics.ConfigSyncsTotal.Inc() - if err := am.mimirClient.CreateGrafanaAlertmanagerConfig( - ctx, - cfg, - hash, - createdAt, - isDefault, - ); err != nil { + if err := am.mimirClient.CreateGrafanaAlertmanagerConfig(ctx, &cfg); err != nil { am.metrics.ConfigSyncErrorsTotal.Inc() return err } @@ -422,40 +424,25 @@ func (am *Alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.P return err } - payload, err := am.buildConfiguration(ctx, rawCopy) + payload, err := am.buildConfiguration(ctx, rawCopy, time.Now().Unix()) if err != nil { return fmt.Errorf("unable to build configuration: %w", err) } - rawCfg, err := json.Marshal(payload) - if err != nil { - return err - } - hash := fmt.Sprintf("%x", md5.Sum(rawCfg)) - - return am.sendConfiguration(ctx, payload, hash, time.Now().Unix(), false) + return am.sendConfiguration(ctx, payload) } // SaveAndApplyDefaultConfig sends the default Grafana Alertmanager configuration to the remote Alertmanager. func (am *Alertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error { am.log.Debug("Sending default configuration to a remote Alertmanager", "url", am.url) - payload, err := am.buildConfiguration(ctx, []byte(am.defaultConfig)) + payload, err := am.buildConfiguration(ctx, []byte(am.defaultConfig), time.Now().Unix()) if err != nil { return fmt.Errorf("unable to build default configuration: %w", err) } - - rawCfg, err := json.Marshal(payload) - if err != nil { - return err - } - hash := fmt.Sprintf("%x", md5.Sum(rawCfg)) - + payload.Default = true // override default status return am.sendConfiguration( ctx, payload, - hash, - time.Now().Unix(), - true, ) } @@ -696,37 +683,29 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) { // shouldSendConfig compares the remote Alertmanager configuration with our local one. // It returns true if the configurations are different. func (am *Alertmanager) shouldSendConfig(ctx context.Context, hash string) bool { + if hash == "" { // empty hash means that something went wrong while calculating it. In this case, always send the config. + return true + } rc, err := am.mimirClient.GetGrafanaAlertmanagerConfig(ctx) if err != nil { // Log the error and return true so we try to upload our config anyway. am.log.Warn("Unable to get the remote Alertmanager configuration for comparison, sending the configuration without comparing", "err", err) return true } - - if rc.Promoted != am.mimirClient.ShouldPromoteConfig() { + if rc.Hash != hash { + am.log.Debug("Hash of the remote Alertmanager configuration is different, sending the configuration", "remote", rc.Hash, "local", hash) return true } + return false +} - // Compare SMTP configs. - if rc.SmtpConfig.EhloIdentity != am.smtp.EhloIdentity || - rc.SmtpConfig.Password != am.smtp.Password || - rc.SmtpConfig.FromAddress != am.smtp.FromAddress || - rc.SmtpConfig.FromName != am.smtp.FromName || - rc.SmtpConfig.Host != am.smtp.Host || - rc.SmtpConfig.SkipVerify != am.smtp.SkipVerify || - rc.SmtpConfig.StartTLSPolicy != am.smtp.StartTLSPolicy || - len(rc.SmtpConfig.StaticHeaders) != len(am.smtp.StaticHeaders) || - rc.SmtpConfig.User != am.smtp.User { - am.log.Debug("SMTP config is different, sending the configuration to the remote Alertmanager") - return true - } +func calculateUserGrafanaConfigHash(config remoteClient.UserGrafanaConfig) (string, error) { + // Ignore some fields when calculating the hash. Make sure the original struct is not modified after that. + config.Default = false + config.CreatedAt = 0 // ignore createdAt to support comparison with hash of default config + config.Hash = "" - for k, v := range rc.SmtpConfig.StaticHeaders { - if value, ok := am.smtp.StaticHeaders[k]; !ok || v != value { - am.log.Debug("SMTP static headers are different, sending the configuration to the remote Alertmanager") - return true - } - } - - return rc.Hash != hash + hasher := fnv.New64a() + hash.DeepHashObject(hasher, &config) + return fmt.Sprintf("%x", hasher.Sum64()), nil } diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index 6ce6abb8994..10ea5e45eaf 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -19,10 +19,13 @@ import ( "time" "github.com/go-openapi/strfmt" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/client_golang/prometheus" + common_config "github.com/prometheus/common/config" "github.com/stretchr/testify/require" alertingClusterPB "github.com/grafana/alerting/cluster/clusterpb" @@ -501,15 +504,6 @@ func TestCompareAndSendConfiguration(t *testing.T) { AlertmanagerConfig: testAutogenRoutes.AlertmanagerConfig, } - // Calculate hashes for expected configurations - cfgWithDecryptedSecretBytes, err := json.Marshal(cfgWithDecryptedSecret) - require.NoError(t, err) - cfgWithDecryptedSecretHash := fmt.Sprintf("%x", md5.Sum(cfgWithDecryptedSecretBytes)) - - cfgWithAutogenRoutesBytes, err := json.Marshal(cfgWithAutogenRoutes) - require.NoError(t, err) - cfgWithAutogenRoutesHash := fmt.Sprintf("%x", md5.Sum(cfgWithAutogenRoutesBytes)) - cfgWithExtraUnmergedBytes, err := testData.ReadFile(path.Join("test-data", "config-with-extra.json")) require.NoError(t, err) cfgWithExtraUnmerged, err := notifier.Load(cfgWithExtraUnmergedBytes) @@ -521,9 +515,6 @@ func TestCompareAndSendConfiguration(t *testing.T) { AlertmanagerConfig: r.Config, Templates: definition.TemplatesMapToPostableAPITemplates(cfgWithExtraUnmerged.ExtraConfigs[0].TemplateFiles, definition.MimirTemplateKind), } - cfgWithExtraMergedBytes, err := json.Marshal(cfgWithExtraMerged) - require.NoError(t, err) - cfgWithExtraMergedHash := fmt.Sprintf("%x", md5.Sum(cfgWithExtraMergedBytes)) tests := []struct { name string @@ -566,7 +557,6 @@ func TestCompareAndSendConfiguration(t *testing.T) { NoopAutogenFn, &client.UserGrafanaConfig{ GrafanaAlertmanagerConfig: cfgWithDecryptedSecret, - Hash: cfgWithDecryptedSecretHash, }, nil, }, @@ -576,7 +566,6 @@ func TestCompareAndSendConfiguration(t *testing.T) { testAutogenFn, &client.UserGrafanaConfig{ GrafanaAlertmanagerConfig: cfgWithAutogenRoutes, - Hash: cfgWithAutogenRoutesHash, }, nil, }, @@ -586,7 +575,6 @@ func TestCompareAndSendConfiguration(t *testing.T) { autogenFn: NoopAutogenFn, expCfg: &client.UserGrafanaConfig{ GrafanaAlertmanagerConfig: cfgWithExtraMerged, - Hash: cfgWithExtraMergedHash, }, }, } @@ -614,9 +602,26 @@ func TestCompareAndSendConfiguration(t *testing.T) { err = am.CompareAndSendConfiguration(ctx, &cfg) if len(test.expErrContains) == 0 { require.NoError(tt, err) - rawCfg, err := json.Marshal(test.expCfg) + + var gotCfg client.UserGrafanaConfig + require.NoError(tt, json.Unmarshal([]byte(got), &gotCfg)) + + require.NotEmpty(tt, gotCfg.Hash) + require.Empty(tt, cmp.Diff(test.expCfg, &gotCfg, + cmpopts.IgnoreFields(client.UserGrafanaConfig{}, "Hash"), // do not compare hashes because the config is processed slightly different: empty maps are nils. + cmpopts.EquateEmpty(), + cmpopts.IgnoreUnexported( + time.Location{}, + labels.Matcher{}, + common_config.ProxyConfig{}))) + + got1 := got + got = "" + err = am.CompareAndSendConfiguration(ctx, &cfg) require.NoError(tt, err) - require.JSONEq(tt, string(rawCfg), got) + + got2 := got + require.Equalf(tt, got1, got2, "Configuration is not idempotent") return } for _, expErr := range test.expErrContains { @@ -815,12 +820,7 @@ receivers: require.NotNil(t, extraReceiver) require.Len(t, extraReceiver.EmailConfigs, 1) require.Equal(t, "alerts@grafana.com", extraReceiver.EmailConfigs[0].To) - - // Verify the config hash - expectedConfigBytes, err := json.Marshal(configSent.GrafanaAlertmanagerConfig) - require.NoError(t, err) - expectedHash := fmt.Sprintf("%x", md5.Sum(expectedConfigBytes)) - require.Equal(t, expectedHash, configSent.Hash) + require.NotEmpty(t, configSent.Hash) } func TestCompareAndSendConfigurationWithExtraConfigs(t *testing.T) { @@ -934,10 +934,7 @@ receivers: require.True(t, found) // Verify the config hash - expectedConfigBytes, err := json.Marshal(configSent.GrafanaAlertmanagerConfig) - require.NoError(t, err) - expectedHash := fmt.Sprintf("%x", md5.Sum(expectedConfigBytes)) - require.Equal(t, expectedHash, configSent.Hash) + require.NotEmpty(t, configSent.Hash) } func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { @@ -961,11 +958,10 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { DefaultConfig: defaultGrafanaConfig, } - testConfigHash := fmt.Sprintf("%x", md5.Sum([]byte(testGrafanaConfig))) testConfigCreatedAt := time.Now().Unix() testConfig := &ngmodels.AlertConfiguration{ AlertmanagerConfiguration: testGrafanaConfig, - ConfigurationHash: testConfigHash, + ConfigurationHash: "", ConfigurationVersion: "v2", CreatedAt: testConfigCreatedAt, OrgID: 1, @@ -1012,7 +1008,6 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { rawCfg, err := json.Marshal(config.GrafanaAlertmanagerConfig) require.NoError(t, err) require.JSONEq(t, testGrafanaConfig, string(rawCfg)) - require.Equal(t, testConfigHash, config.Hash) require.Equal(t, testConfigCreatedAt, config.CreatedAt) require.Equal(t, testConfig.Default, config.Default) @@ -1038,7 +1033,6 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { rawCfg, err := json.Marshal(config.GrafanaAlertmanagerConfig) require.NoError(t, err) require.JSONEq(t, testGrafanaConfig, string(rawCfg)) - require.Equal(t, testConfigHash, config.Hash) require.Equal(t, testConfigCreatedAt, config.CreatedAt) require.False(t, config.Default) @@ -1085,9 +1079,6 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { require.JSONEq(t, testGrafanaConfigWithSecret, string(got)) - // Verify that the hash is calculated from the final configuration, including simplified routing - expectedHash := fmt.Sprintf("%x", md5.Sum(got)) - require.Equal(t, expectedHash, config.Hash, "Hash should be calculated from the final processed configuration") require.False(t, config.Default) // An error while adding auto-generated rutes should be returned. @@ -1114,7 +1105,6 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { require.NoError(t, err) require.JSONEq(t, string(want), string(got)) - require.Equal(t, fmt.Sprintf("%x", md5.Sum(want)), config.Hash) require.True(t, config.Default) // An error while adding auto-generated rutes should be returned. diff --git a/pkg/services/ngalert/remote/client/alertmanager_configuration.go b/pkg/services/ngalert/remote/client/alertmanager_configuration.go index b812c146687..a53132a8812 100644 --- a/pkg/services/ngalert/remote/client/alertmanager_configuration.go +++ b/pkg/services/ngalert/remote/client/alertmanager_configuration.go @@ -39,10 +39,6 @@ type UserGrafanaConfig struct { SmtpConfig SmtpConfig `json:"smtp_config"` } -func (mc *Mimir) ShouldPromoteConfig() bool { - return mc.promoteConfig -} - func (mc *Mimir) GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafanaConfig, error) { gc := &UserGrafanaConfig{} response := successResponse{ @@ -62,16 +58,8 @@ func (mc *Mimir) GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafana return gc, nil } -func (mc *Mimir) CreateGrafanaAlertmanagerConfig(ctx context.Context, cfg GrafanaAlertmanagerConfig, hash string, createdAt int64, isDefault bool) error { - payload, err := definition.MarshalJSONWithSecrets(&UserGrafanaConfig{ - GrafanaAlertmanagerConfig: cfg, - Hash: hash, - CreatedAt: createdAt, - Default: isDefault, - Promoted: mc.promoteConfig, - ExternalURL: mc.externalURL, - SmtpConfig: mc.smtpConfig, - }) +func (mc *Mimir) CreateGrafanaAlertmanagerConfig(ctx context.Context, cfg *UserGrafanaConfig) error { + payload, err := definition.MarshalJSONWithSecrets(cfg) if err != nil { return err } diff --git a/pkg/services/ngalert/remote/client/mimir.go b/pkg/services/ngalert/remote/client/mimir.go index 1533e24c208..d8f9a51f327 100644 --- a/pkg/services/ngalert/remote/client/mimir.go +++ b/pkg/services/ngalert/remote/client/mimir.go @@ -30,26 +30,21 @@ type MimirClient interface { DeleteGrafanaAlertmanagerState(ctx context.Context) error GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafanaConfig, error) - CreateGrafanaAlertmanagerConfig(ctx context.Context, configuration GrafanaAlertmanagerConfig, hash string, createdAt int64, isDefault bool) error + CreateGrafanaAlertmanagerConfig(ctx context.Context, config *UserGrafanaConfig) error DeleteGrafanaAlertmanagerConfig(ctx context.Context) error TestTemplate(ctx context.Context, c alertingNotify.TestTemplatesConfigBodyParams) (*alertingNotify.TestTemplatesResults, error) TestReceivers(ctx context.Context, c alertingNotify.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) - ShouldPromoteConfig() bool - // Mimir implements an extended version of the receivers API under a different path. GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) } type Mimir struct { - client client.Requester - endpoint *url.URL - logger log.Logger - metrics *metrics.RemoteAlertmanager - promoteConfig bool - externalURL string - smtpConfig SmtpConfig + client client.Requester + endpoint *url.URL + logger log.Logger + metrics *metrics.RemoteAlertmanager } type SmtpConfig struct { @@ -69,10 +64,7 @@ type Config struct { TenantID string Password string - Logger log.Logger - PromoteConfig bool - ExternalURL string - Smtp SmtpConfig + Logger log.Logger } // successResponse represents a successful response from the Mimir API. @@ -110,13 +102,10 @@ func New(cfg *Config, metrics *metrics.RemoteAlertmanager, tracer tracing.Tracer trc := client.NewTracedClient(tc, tracer, "remote.alertmanager.client") return &Mimir{ - endpoint: cfg.URL, - client: trc, - logger: cfg.Logger, - metrics: metrics, - promoteConfig: cfg.PromoteConfig, - externalURL: cfg.ExternalURL, - smtpConfig: cfg.Smtp, + endpoint: cfg.URL, + client: trc, + logger: cfg.Logger, + metrics: metrics, }, nil } diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index 8b0d17a381c..66c4ff17f85 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -78,6 +78,38 @@ func TestIntegrationParseMetricRequest(t *testing.T) { assert.Len(t, parsedReq.getFlattenedQueries(), 2) }) + t.Run("Test a simple single datasource query with missing time range", func(t *testing.T) { + tc := setup(t, false, nil) + mr := metricRequestWithQueries(t, `{ + "refId": "A", + "datasource": { + "uid": "gIEkMvIVz", + "type": "postgres" + } + }`, `{ + "refId": "B", + "datasource": { + "uid": "gIEkMvIVz", + "type": "postgres" + } + }`) + mr.From = "" + mr.To = "" + parsedReq, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) + require.NoError(t, err) + require.NotNil(t, parsedReq) + assert.False(t, parsedReq.hasExpression) + assert.Len(t, parsedReq.parsedQueries, 1) + assert.Contains(t, parsedReq.parsedQueries, "gIEkMvIVz") + queries := parsedReq.getFlattenedQueries() + assert.Len(t, queries, 2) + + for _, q := range queries { + require.Equal(t, int64(0), q.query.TimeRange.From.UnixMilli()) + require.Equal(t, int64(0), q.query.TimeRange.To.UnixMilli()) + } + }) + t.Run("Test a single datasource query with expressions", func(t *testing.T) { tc := setup(t, false, nil) mr := metricRequestWithQueries(t, `{ diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go index f691cb0c5a3..6a033b3b248 100644 --- a/pkg/services/sqlstore/database_wrapper.go +++ b/pkg/services/sqlstore/database_wrapper.go @@ -10,9 +10,9 @@ import ( "github.com/gchaincl/sqlhooks" "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana/pkg/util/sqlite" "github.com/grafana/grafana/pkg/util/xorm/core" "github.com/lib/pq" - "github.com/mattn/go-sqlite3" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -42,7 +42,7 @@ func init() { // database queries. It also registers the metrics. func WrapDatabaseDriverWithHooks(dbType string, tracer tracing.Tracer) string { drivers := map[string]driver.Driver{ - migrator.SQLite: &sqlite3.SQLiteDriver{}, + migrator.SQLite: &sqlite.Driver{}, migrator.MySQL: &mysql.MySQLDriver{}, migrator.Postgres: &pq.Driver{}, } diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index d8e55c4ee71..75ebf8e540b 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -2,15 +2,14 @@ package migrator import ( "context" - "errors" "fmt" "strings" "time" _ "github.com/go-sql-driver/mysql" "github.com/golang-migrate/migrate/v4/database" + "github.com/grafana/grafana/pkg/util/sqlite" _ "github.com/lib/pq" - "github.com/mattn/go-sqlite3" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -326,7 +325,7 @@ func (mg *Migrator) doMigration(ctx context.Context, m Migration) error { err := mg.exec(ctx, m, sess) // if we get an sqlite busy/locked error, sleep 100ms and try again cnt := 0 - for cnt < 3 && (errors.Is(err, sqlite3.ErrLocked) || errors.Is(err, sqlite3.ErrBusy)) { + for cnt < 3 && sqlite.IsBusyOrLocked(err) { cnt++ logger.Debug("Database locked, sleeping then retrying", "error", err, "sql", sql) span.AddEvent("Database locked, sleeping then retrying", diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index 6c0e77972d8..d4302c18181 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -1,12 +1,10 @@ package migrator import ( - "errors" "fmt" "strings" - "github.com/mattn/go-sqlite3" - + "github.com/grafana/grafana/pkg/util/sqlite" "github.com/grafana/grafana/pkg/util/xorm" ) @@ -139,27 +137,12 @@ func (db *SQLite3) TruncateDBTables(engine *xorm.Engine) error { return nil } -func (db *SQLite3) isThisError(err error, errcode int) bool { - var driverErr sqlite3.Error - if errors.As(err, &driverErr) { - if int(driverErr.ExtendedCode) == errcode { - return true - } - } - - return false -} - func (db *SQLite3) ErrorMessage(err error) string { - var driverErr sqlite3.Error - if errors.As(err, &driverErr) { - return driverErr.Error() - } - return "" + return sqlite.ErrorMessage(err) } func (db *SQLite3) IsUniqueConstraintViolation(err error) bool { - return db.isThisError(err, int(sqlite3.ErrConstraintUnique)) || db.isThisError(err, int(sqlite3.ErrConstraintPrimaryKey)) + return sqlite.IsUniqueConstraintViolation(err) } func (db *SQLite3) IsDeadlock(err error) bool { diff --git a/pkg/services/sqlstore/session_test.go b/pkg/services/sqlstore/session_test.go index a30f11e6d11..0f2d0629a47 100644 --- a/pkg/services/sqlstore/session_test.go +++ b/pkg/services/sqlstore/session_test.go @@ -6,10 +6,10 @@ import ( "fmt" "testing" - "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/sqlite" ) func TestIntegration_RetryingDisabled(t *testing.T) { @@ -144,7 +144,7 @@ func getRetryErrors(t *testing.T, store *SQLStore) []error { var retryErrors []error switch store.GetDialect().DriverName() { case migrator.SQLite: - retryErrors = []error{sqlite3.Error{Code: sqlite3.ErrBusy}, sqlite3.Error{Code: sqlite3.ErrLocked}} + retryErrors = []error{sqlite.TestErrBusy, sqlite.TestErrLocked} } if len(retryErrors) == 0 { diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 8506acdab79..7d3d09a5ff3 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -22,6 +22,11 @@ type TestDB struct { DriverName string ConnStr string Path string + Host string + Port string + User string + Password string + Database string Cleanup func() } @@ -132,6 +137,11 @@ func mySQLTestDB() (*TestDB, error) { return &TestDB{ DriverName: "mysql", ConnStr: conn_str, + Host: host, + Port: port, + User: "grafana", + Password: "password", + Database: "grafana_tests", Cleanup: func() {}, }, nil } @@ -149,6 +159,11 @@ func postgresTestDB() (*TestDB, error) { return &TestDB{ DriverName: "postgres", ConnStr: connStr, + Host: host, + Port: port, + User: "grafanatest", + Password: "grafanatest", + Database: "grafanatest", Cleanup: func() {}, }, nil } diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 665c751532f..0187652cb0e 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -121,7 +121,6 @@ type UnifiedAlertingSettings struct { DefaultRuleEvaluationInterval time.Duration Screenshots UnifiedAlertingScreenshotSettings ReservedLabels UnifiedAlertingReservedLabelSettings - SkipClustering bool StateHistory UnifiedAlertingStateHistorySettings NotificationHistory UnifiedAlertingNotificationHistorySettings RemoteAlertmanager RemoteAlertmanagerSettings diff --git a/pkg/storage/secret/metadata/data/secure_value_create.sql b/pkg/storage/secret/metadata/data/secure_value_create.sql index a120feaa5e5..66d404f5bd2 100644 --- a/pkg/storage/secret/metadata/data/secure_value_create.sql +++ b/pkg/storage/secret/metadata/data/secure_value_create.sql @@ -20,6 +20,18 @@ INSERT INTO {{ .Ident "secret_secure_value" }} ( {{ if .Row.Ref.Valid }} {{ .Ident "ref" }}, {{ end }} + {{ if .Row.OwnerReferenceAPIGroup.Valid }} + {{ .Ident "owner_reference_api_group" }}, + {{ end }} + {{ if .Row.OwnerReferenceAPIVersion.Valid }} + {{ .Ident "owner_reference_api_version" }}, + {{ end }} + {{ if .Row.OwnerReferenceKind.Valid }} + {{ .Ident "owner_reference_kind" }}, + {{ end }} + {{ if .Row.OwnerReferenceName.Valid }} + {{ .Ident "owner_reference_name" }}, + {{ end }} {{ .Ident "external_id" }} ) VALUES ( {{ .Arg .Row.GUID }}, @@ -43,5 +55,17 @@ INSERT INTO {{ .Ident "secret_secure_value" }} ( {{ if .Row.Ref.Valid }} {{ .Arg .Row.Ref.String }}, {{ end }} + {{ if .Row.OwnerReferenceAPIGroup.Valid }} + {{ .Arg .Row.OwnerReferenceAPIGroup.String }}, + {{ end }} + {{ if .Row.OwnerReferenceAPIVersion.Valid }} + {{ .Arg .Row.OwnerReferenceAPIVersion.String }}, + {{ end }} + {{ if .Row.OwnerReferenceKind.Valid }} + {{ .Arg .Row.OwnerReferenceKind.String }}, + {{ end }} + {{ if .Row.OwnerReferenceName.Valid }} + {{ .Arg .Row.OwnerReferenceName.String }}, + {{ end }} {{ .Arg .Row.ExternalID }} ); \ No newline at end of file diff --git a/pkg/storage/secret/metadata/data/secure_value_list.sql b/pkg/storage/secret/metadata/data/secure_value_list.sql index 9c0c604b72d..5d2ecf51e34 100644 --- a/pkg/storage/secret/metadata/data/secure_value_list.sql +++ b/pkg/storage/secret/metadata/data/secure_value_list.sql @@ -14,7 +14,11 @@ SELECT {{ .Ident "ref" }}, {{ .Ident "external_id" }}, {{ .Ident "version" }}, - {{ .Ident "active" }} + {{ .Ident "active" }}, + {{ .Ident "owner_reference_api_group" }}, + {{ .Ident "owner_reference_api_version" }}, + {{ .Ident "owner_reference_kind" }}, + {{ .Ident "owner_reference_name" }} FROM {{ .Ident "secret_secure_value" }} WHERE diff --git a/pkg/storage/secret/metadata/data/secure_value_read.sql b/pkg/storage/secret/metadata/data/secure_value_read.sql index b90d54b4a5f..4f6e0ae0707 100644 --- a/pkg/storage/secret/metadata/data/secure_value_read.sql +++ b/pkg/storage/secret/metadata/data/secure_value_read.sql @@ -14,7 +14,11 @@ SELECT {{ .Ident "ref" }}, {{ .Ident "external_id" }}, {{ .Ident "active" }}, - {{ .Ident "version" }} + {{ .Ident "version" }}, + {{ .Ident "owner_reference_api_group" }}, + {{ .Ident "owner_reference_api_version" }}, + {{ .Ident "owner_reference_kind" }}, + {{ .Ident "owner_reference_name" }} FROM {{ .Ident "secret_secure_value" }} WHERE diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index 03d0daace36..3c35a443794 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -292,8 +292,13 @@ func TestIntegrationDecrypt(t *testing.T) { require.NotEmpty(t, exposed) require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue()) - require.Len(t, fakeLogger.InfoArgs, 1) - args := fakeLogger.InfoArgs[0] + require.Len(t, fakeLogger.InfoMsgs, 2) + require.Equal(t, fakeLogger.InfoMsgs[0], "SecureValueMetadataStorage.Read") + require.Equal(t, fakeLogger.InfoMsgs[1], "Secrets Audit Log") + + require.Len(t, fakeLogger.InfoArgs, 2) + // we only want to check the audit log args + args := fakeLogger.InfoArgs[1] require.Contains(t, args, "grafana_decrypter_identity") require.Contains(t, args, "decrypter_identity") for i, arg := range args { diff --git a/pkg/storage/secret/metadata/metrics/metrics.go b/pkg/storage/secret/metadata/metrics/metrics.go index aea3817f12c..f9baba1de64 100644 --- a/pkg/storage/secret/metadata/metrics/metrics.go +++ b/pkg/storage/secret/metadata/metrics/metrics.go @@ -25,12 +25,8 @@ type StorageMetrics struct { KeeperMetadataListCount prometheus.Counter KeeperMetadataGetKeeperConfigDuration prometheus.Histogram - SecureValueMetadataCreateDuration prometheus.Histogram - SecureValueMetadataCreateCount prometheus.Counter - SecureValueMetadataUpdateDuration prometheus.Histogram - SecureValueMetadataUpdateCount prometheus.Counter - SecureValueMetadataDeleteDuration prometheus.Histogram - SecureValueMetadataDeleteCount prometheus.Counter + SecureValueMetadataCreateDuration *prometheus.HistogramVec + SecureValueMetadataCreateCount *prometheus.CounterVec SecureValueMetadataGetDuration prometheus.Histogram SecureValueMetadataGetCount prometheus.Counter SecureValueMetadataListDuration prometheus.Histogram @@ -119,45 +115,19 @@ func newStorageMetrics() *StorageMetrics { }), // Secure value metrics - SecureValueMetadataCreateDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + SecureValueMetadataCreateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_metadata_create_duration_seconds", Help: "Duration of secure value metadata create operations", Buckets: prometheus.DefBuckets, - }), - SecureValueMetadataCreateCount: prometheus.NewCounter(prometheus.CounterOpts{ + }, []string{"successful"}), + SecureValueMetadataCreateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_metadata_create_count", Help: "Count of secure value metadata create operations", - }), - SecureValueMetadataUpdateDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_update_duration_seconds", - Help: "Duration of secure value metadata update operations", - Buckets: prometheus.DefBuckets, - }), - SecureValueMetadataUpdateCount: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_update_count", - Help: "Count of secure value metadata update operations", - }), - SecureValueMetadataDeleteDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_delete_duration_seconds", - Help: "Duration of secure value metadata delete operations", - Buckets: prometheus.DefBuckets, - }), - SecureValueMetadataDeleteCount: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_delete_count", - Help: "Count of secure value metadata delete operations", - }), + }, []string{"successful"}), SecureValueMetadataGetDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, @@ -241,10 +211,6 @@ func NewStorageMetrics(reg prometheus.Registerer) *StorageMetrics { m.KeeperMetadataGetKeeperConfigDuration, m.SecureValueMetadataCreateDuration, m.SecureValueMetadataCreateCount, - m.SecureValueMetadataUpdateDuration, - m.SecureValueMetadataUpdateCount, - m.SecureValueMetadataDeleteDuration, - m.SecureValueMetadataDeleteCount, m.SecureValueMetadataGetDuration, m.SecureValueMetadataGetCount, m.SecureValueMetadataListDuration, diff --git a/pkg/storage/secret/metadata/query_test.go b/pkg/storage/secret/metadata/query_test.go index 915fd261ec2..2b0c232f2c1 100644 --- a/pkg/storage/secret/metadata/query_test.go +++ b/pkg/storage/secret/metadata/query_test.go @@ -177,21 +177,25 @@ func TestSecureValueQueries(t *testing.T) { Data: &createSecureValue{ SQLTemplate: mocks.NewTestingSQLTemplate(), Row: &secureValueDB{ - GUID: "abc", - Name: "name", - Namespace: "ns", - Annotations: `{"x":"XXXX"}`, - Labels: `{"a":"AAA", "b", "BBBB"}`, - Created: 1234, - CreatedBy: "user:ryan", - Updated: 5678, - UpdatedBy: "user:cameron", - Version: 1, - Description: "description", - Keeper: toNullString(nil), - Decrypters: toNullString(nil), - Ref: toNullString(nil), - ExternalID: "extId", + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Version: 1, + Description: "description", + Keeper: toNullString(nil), + Decrypters: toNullString(nil), + Ref: toNullString(nil), + ExternalID: "extId", + OwnerReferenceAPIGroup: toNullString(nil), + OwnerReferenceAPIVersion: toNullString(nil), + OwnerReferenceKind: toNullString(nil), + OwnerReferenceName: toNullString(nil), }, }, }, @@ -200,21 +204,25 @@ func TestSecureValueQueries(t *testing.T) { Data: &createSecureValue{ SQLTemplate: mocks.NewTestingSQLTemplate(), Row: &secureValueDB{ - GUID: "abc", - Name: "name", - Namespace: "ns", - Annotations: `{"x":"XXXX"}`, - Labels: `{"a":"AAA", "b", "BBBB"}`, - Created: 1234, - CreatedBy: "user:ryan", - Updated: 5678, - UpdatedBy: "user:cameron", - Version: 1, - Description: "description", - Keeper: toNullString(ptr.To("keeper_test")), - Decrypters: toNullString(ptr.To("decrypters_test")), - Ref: toNullString(ptr.To("ref_test")), - ExternalID: "extId", + GUID: "abc", + Name: "name", + Namespace: "ns", + Annotations: `{"x":"XXXX"}`, + Labels: `{"a":"AAA", "b", "BBBB"}`, + Created: 1234, + CreatedBy: "user:ryan", + Updated: 5678, + UpdatedBy: "user:cameron", + Version: 1, + Description: "description", + Keeper: toNullString(ptr.To("keeper_test")), + Decrypters: toNullString(ptr.To("decrypters_test")), + Ref: toNullString(ptr.To("ref_test")), + ExternalID: "extId", + OwnerReferenceAPIGroup: toNullString(ptr.To("prometheus.datasource.grafana.app")), + OwnerReferenceAPIVersion: toNullString(ptr.To("v0alpha1")), + OwnerReferenceKind: toNullString(ptr.To("DataSource")), + OwnerReferenceName: toNullString(ptr.To("prom-config")), }, }, }, diff --git a/pkg/storage/secret/metadata/secure_value_model.go b/pkg/storage/secret/metadata/secure_value_model.go index 6e5c8511784..c64411bf19b 100644 --- a/pkg/storage/secret/metadata/secure_value_model.go +++ b/pkg/storage/secret/metadata/secure_value_model.go @@ -10,22 +10,26 @@ import ( secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" - "github.com/grafana/grafana/pkg/storage/secret/migrator" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" ) type secureValueDB struct { // Kubernetes Metadata - GUID string - Name string - Namespace string - Annotations string // map[string]string - Labels string // map[string]string - Created int64 - CreatedBy string - Updated int64 - UpdatedBy string + GUID string + Name string + Namespace string + Annotations string // map[string]string + Labels string // map[string]string + Created int64 + CreatedBy string + Updated int64 + UpdatedBy string + OwnerReferenceAPIGroup sql.NullString + OwnerReferenceAPIVersion sql.NullString + OwnerReferenceKind sql.NullString + OwnerReferenceName sql.NullString // Kubernetes Status Active bool @@ -39,10 +43,6 @@ type secureValueDB struct { ExternalID string } -func (*secureValueDB) TableName() string { - return migrator.TableNameSecureValue -} - // toKubernetes maps a DB row into a Kubernetes resource (metadata + spec). func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) { annotations := make(map[string]string, 0) @@ -85,8 +85,6 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) { resource.Spec.Ref = &sv.Ref.String } - resource.Status.ExternalID = sv.ExternalID - // Set all meta fields here for consistency. meta, err := utils.MetaAccessor(resource) if err != nil { @@ -106,6 +104,20 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) { meta.SetUpdatedTimestamp(&updated) meta.SetResourceVersionInt64(sv.Updated) + hasOwnerReference := sv.OwnerReferenceAPIGroup.Valid && sv.OwnerReferenceAPIGroup.String != "" && + sv.OwnerReferenceAPIVersion.Valid && sv.OwnerReferenceAPIVersion.String != "" && + sv.OwnerReferenceKind.Valid && sv.OwnerReferenceKind.String != "" && + sv.OwnerReferenceName.Valid && sv.OwnerReferenceName.String != "" + if hasOwnerReference { + meta.SetOwnerReferences([]metav1.OwnerReference{ + { + APIVersion: schema.GroupVersion{Group: sv.OwnerReferenceAPIGroup.String, Version: sv.OwnerReferenceAPIVersion.String}.String(), + Kind: sv.OwnerReferenceKind.String, + Name: sv.OwnerReferenceName.String, + }, + }) + } + return resource, nil } @@ -179,16 +191,48 @@ func toRow(sv *secretv1beta1.SecureValue, externalID string) (*secureValueDB, er return nil, fmt.Errorf("failed to get resource version: %w", err) } + var ( + ownerReferenceAPIGroup sql.NullString + ownerReferenceAPIVersion sql.NullString + ownerReferenceKind sql.NullString + ownerReferenceName sql.NullString + ) + + ownerReferences := meta.GetOwnerReferences() + if len(ownerReferences) > 1 { + return nil, fmt.Errorf("only one owner reference is supported, found %d", len(ownerReferences)) + } + if len(ownerReferences) == 1 { + ownerReference := ownerReferences[0] + + gv, err := schema.ParseGroupVersion(ownerReference.APIVersion) + if err != nil { + return nil, fmt.Errorf("failed to parse owner reference API version %s: %w", ownerReference.APIVersion, err) + } + if gv.Group == "" { + return nil, fmt.Errorf("malformed api version %s requires / format", ownerReference.APIVersion) + } + + ownerReferenceAPIGroup = toNullString(&gv.Group) + ownerReferenceAPIVersion = toNullString(&gv.Version) + ownerReferenceKind = toNullString(&ownerReference.Kind) + ownerReferenceName = toNullString(&ownerReference.Name) + } + return &secureValueDB{ - GUID: string(sv.UID), - Name: sv.Name, - Namespace: sv.Namespace, - Annotations: annotations, - Labels: labels, - Created: meta.GetCreationTimestamp().UnixMilli(), - CreatedBy: meta.GetCreatedBy(), - Updated: updatedTimestamp, - UpdatedBy: meta.GetUpdatedBy(), + GUID: string(sv.UID), + Name: sv.Name, + Namespace: sv.Namespace, + Annotations: annotations, + Labels: labels, + Created: meta.GetCreationTimestamp().UnixMilli(), + CreatedBy: meta.GetCreatedBy(), + Updated: updatedTimestamp, + UpdatedBy: meta.GetUpdatedBy(), + OwnerReferenceAPIGroup: ownerReferenceAPIGroup, + OwnerReferenceAPIVersion: ownerReferenceAPIVersion, + OwnerReferenceKind: ownerReferenceKind, + OwnerReferenceName: ownerReferenceName, Version: sv.Status.Version, diff --git a/pkg/storage/secret/metadata/secure_value_store.go b/pkg/storage/secret/metadata/secure_value_store.go index 3e539b83b3e..5782ee9a3e4 100644 --- a/pkg/storage/secret/metadata/secure_value_store.go +++ b/pkg/storage/secret/metadata/secure_value_store.go @@ -3,18 +3,21 @@ package metadata import ( "context" "fmt" + "strconv" "time" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana-app-sdk/logging" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/storage/secret/metadata/metrics" "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "go.opentelemetry.io/otel/codes" ) var _ contracts.SecureValueMetadataStorage = (*secureValueMetadataStorage)(nil) @@ -40,16 +43,39 @@ type secureValueMetadataStorage struct { tracer trace.Tracer } -func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) { +func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, svmCreateErr error) { start := time.Now() + name := sv.GetName() + namespace := sv.GetNamespace() ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.Create", trace.WithAttributes( - attribute.String("name", sv.GetName()), - attribute.String("namespace", sv.GetNamespace()), + attribute.String("name", name), + attribute.String("namespace", namespace), attribute.String("actorUID", actorUID), )) defer span.End() - // Set inside of the transaction callback + defer func() { + args := []any{ + "name", name, + "namespace", namespace, + "actorUID", actorUID, + } + + success := svmCreateErr == nil + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "SecureValueMetadataStorage.Create failed") + span.RecordError(svmCreateErr) + args = append(args, "error", svmCreateErr) + } + + logging.FromContext(ctx).Info("SecureValueMetadataStorage.Create", args...) + + s.metrics.SecureValueMetadataCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + s.metrics.SecureValueMetadataCreateCount.WithLabelValues(strconv.FormatBool(success)).Inc() + }() + + // Set inside the transaction callback var row *secureValueDB err := s.db.Transaction(ctx, func(ctx context.Context) error { @@ -145,9 +171,6 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet return nil, fmt.Errorf("convert to kubernetes object: %w", err) } - s.metrics.SecureValueMetadataCreateDuration.Observe(time.Since(start).Seconds()) - s.metrics.SecureValueMetadataCreateCount.Inc() - return createdSecureValue, nil } @@ -220,7 +243,9 @@ func (s *secureValueMetadataStorage) readActiveVersion(ctx context.Context, name &secureValue.Annotations, &secureValue.Labels, &secureValue.Created, &secureValue.CreatedBy, &secureValue.Updated, &secureValue.UpdatedBy, - &secureValue.Description, &secureValue.Keeper, &secureValue.Decrypters, &secureValue.Ref, &secureValue.ExternalID, &secureValue.Active, &secureValue.Version); err != nil { + &secureValue.Description, &secureValue.Keeper, &secureValue.Decrypters, &secureValue.Ref, &secureValue.ExternalID, &secureValue.Active, &secureValue.Version, + &secureValue.OwnerReferenceAPIGroup, &secureValue.OwnerReferenceAPIVersion, &secureValue.OwnerReferenceKind, &secureValue.OwnerReferenceName, + ); err != nil { return secureValueDB{}, fmt.Errorf("failed to scan secure value row: %w", err) } @@ -230,7 +255,7 @@ func (s *secureValueMetadataStorage) readActiveVersion(ctx context.Context, name return secureValue, nil } -func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string, opts contracts.ReadOpts) (*secretv1beta1.SecureValue, error) { +func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string, opts contracts.ReadOpts) (_ *secretv1beta1.SecureValue, readErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.Read", trace.WithAttributes( attribute.String("name", name), @@ -239,6 +264,13 @@ func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.N )) defer span.End() + defer func() { + logging.FromContext(ctx).Info("SecureValueMetadataStorage.Read", "namespace", namespace, "name", name, "success", readErr == nil, "error", readErr) + + s.metrics.SecureValueMetadataGetDuration.Observe(time.Since(start).Seconds()) + s.metrics.SecureValueMetadataGetCount.Inc() + }() + secureValue, err := s.readActiveVersion(ctx, namespace, name, opts) if err != nil { return nil, err @@ -249,9 +281,6 @@ func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.N return nil, fmt.Errorf("convert to kubernetes object: %w", err) } - s.metrics.SecureValueMetadataGetDuration.Observe(time.Since(start).Seconds()) - s.metrics.SecureValueMetadataGetCount.Inc() - return secureValueKub, nil } @@ -293,6 +322,7 @@ func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.N &row.Updated, &row.UpdatedBy, &row.Description, &row.Keeper, &row.Decrypters, &row.Ref, &row.ExternalID, &row.Version, &row.Active, + &row.OwnerReferenceAPIGroup, &row.OwnerReferenceAPIVersion, &row.OwnerReferenceKind, &row.OwnerReferenceName, ) if err != nil { diff --git a/pkg/storage/secret/metadata/secure_value_test.go b/pkg/storage/secret/metadata/secure_value_test.go index 9400735cdd1..ce1513d95cf 100644 --- a/pkg/storage/secret/metadata/secure_value_test.go +++ b/pkg/storage/secret/metadata/secure_value_test.go @@ -403,7 +403,7 @@ func TestStateMachine(t *testing.T) { }, "decrypt": func(t *rapid.T) { input := decryptGen.Draw(t, "decryptInput") - authCtx := testutils.CreateServiceAuthContext(t.Context(), input.decrypter, []string{fmt.Sprintf("secret.grafana.app/securevalues/%+v:decrypt", input.name)}) + authCtx := testutils.CreateServiceAuthContext(t.Context(), input.decrypter, input.namespace, []string{fmt.Sprintf("secret.grafana.app/securevalues/%+v:decrypt", input.name)}) modelResult, modelErr := model.decrypt(input.decrypter, input.namespace, input.name) result, err := sut.DecryptService.Decrypt(authCtx, input.namespace, input.name) if err != nil || modelErr != nil { @@ -440,7 +440,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) { require.NoError(t, err) require.Equal(t, sv.Status.Version, deletedSv.Status.Version) - authCtx := testutils.CreateServiceAuthContext(t.Context(), sv.Spec.Decrypters[0], []string{fmt.Sprintf("secret.grafana.app/securevalues/%+v:decrypt", sv.Name)}) + authCtx := testutils.CreateServiceAuthContext(t.Context(), sv.Spec.Decrypters[0], sv.Namespace, []string{fmt.Sprintf("secret.grafana.app/securevalues/%+v:decrypt", sv.Name)}) result, err := sut.DecryptService.Decrypt(authCtx, sv.Namespace, sv.Name) require.NoError(t, err) require.Equal(t, 1, len(result)) diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_create-create-not-null.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_create-create-not-null.sql index aadea1d47a9..b629d32fc52 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_create-create-not-null.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_create-create-not-null.sql @@ -14,6 +14,10 @@ INSERT INTO `secret_secure_value` ( `keeper`, `decrypters`, `ref`, + `owner_reference_api_group`, + `owner_reference_api_version`, + `owner_reference_kind`, + `owner_reference_name`, `external_id` ) VALUES ( 'abc', @@ -31,5 +35,9 @@ INSERT INTO `secret_secure_value` ( 'keeper_test', 'decrypters_test', 'ref_test', + 'prometheus.datasource.grafana.app', + 'v0alpha1', + 'DataSource', + 'prom-config', 'extId' ); diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_list-list.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_list-list.sql index d73828bfae8..5faf0cca659 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_list-list.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_list-list.sql @@ -14,7 +14,11 @@ SELECT `ref`, `external_id`, `version`, - `active` + `active`, + `owner_reference_api_group`, + `owner_reference_api_version`, + `owner_reference_kind`, + `owner_reference_name` FROM `secret_secure_value` WHERE diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read-for-update.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read-for-update.sql index 4d8525dd0a0..f48f1a1d703 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read-for-update.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read-for-update.sql @@ -14,7 +14,11 @@ SELECT `ref`, `external_id`, `active`, - `version` + `version`, + `owner_reference_api_group`, + `owner_reference_api_version`, + `owner_reference_kind`, + `owner_reference_name` FROM `secret_secure_value` WHERE diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read.sql index c42fd0037c5..3dfc6f1e0b9 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_read-read.sql @@ -14,7 +14,11 @@ SELECT `ref`, `external_id`, `active`, - `version` + `version`, + `owner_reference_api_group`, + `owner_reference_api_version`, + `owner_reference_kind`, + `owner_reference_name` FROM `secret_secure_value` WHERE diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_create-create-not-null.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_create-create-not-null.sql index cf43e0c130c..bbf94f89fcc 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_create-create-not-null.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_create-create-not-null.sql @@ -14,6 +14,10 @@ INSERT INTO "secret_secure_value" ( "keeper", "decrypters", "ref", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name", "external_id" ) VALUES ( 'abc', @@ -31,5 +35,9 @@ INSERT INTO "secret_secure_value" ( 'keeper_test', 'decrypters_test', 'ref_test', + 'prometheus.datasource.grafana.app', + 'v0alpha1', + 'DataSource', + 'prom-config', 'extId' ); diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_list-list.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_list-list.sql index 40ee42ebcf7..0095a993c35 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_list-list.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_list-list.sql @@ -14,7 +14,11 @@ SELECT "ref", "external_id", "version", - "active" + "active", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read-for-update.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read-for-update.sql index cce38e6db7a..162d97d042c 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read-for-update.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read-for-update.sql @@ -14,7 +14,11 @@ SELECT "ref", "external_id", "active", - "version" + "version", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read.sql index 2d16c211a85..86aab54919d 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_read-read.sql @@ -14,7 +14,11 @@ SELECT "ref", "external_id", "active", - "version" + "version", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_create-create-not-null.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_create-create-not-null.sql index cf43e0c130c..bbf94f89fcc 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_create-create-not-null.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_create-create-not-null.sql @@ -14,6 +14,10 @@ INSERT INTO "secret_secure_value" ( "keeper", "decrypters", "ref", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name", "external_id" ) VALUES ( 'abc', @@ -31,5 +35,9 @@ INSERT INTO "secret_secure_value" ( 'keeper_test', 'decrypters_test', 'ref_test', + 'prometheus.datasource.grafana.app', + 'v0alpha1', + 'DataSource', + 'prom-config', 'extId' ); diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_list-list.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_list-list.sql index 40ee42ebcf7..0095a993c35 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_list-list.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_list-list.sql @@ -14,7 +14,11 @@ SELECT "ref", "external_id", "version", - "active" + "active", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read-for-update.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read-for-update.sql index 2d16c211a85..86aab54919d 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read-for-update.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read-for-update.sql @@ -14,7 +14,11 @@ SELECT "ref", "external_id", "active", - "version" + "version", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read.sql index 2d16c211a85..86aab54919d 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_read-read.sql @@ -14,7 +14,11 @@ SELECT "ref", "external_id", "active", - "version" + "version", + "owner_reference_api_group", + "owner_reference_api_version", + "owner_reference_kind", + "owner_reference_name" FROM "secret_secure_value" WHERE diff --git a/pkg/storage/secret/migrator/migrator.go b/pkg/storage/secret/migrator/migrator.go index 0b7790a875f..a208497c65a 100644 --- a/pkg/storage/secret/migrator/migrator.go +++ b/pkg/storage/secret/migrator/migrator.go @@ -152,4 +152,33 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { Cols: []string{"namespace", "label", "active"}, Type: migrator.IndexType, })) + + // Owner Reference columns + mg.AddMigration("add owner_reference_api_group column to "+TableNameSecureValue, migrator.NewAddColumnMigration(secureValueTable, &migrator.Column{ + Name: "owner_reference_api_group", + Type: migrator.DB_NVarchar, + Length: 253, // Limit enforced by K8s. + Nullable: true, + })) + + mg.AddMigration("add owner_reference_api_version column to "+TableNameSecureValue, migrator.NewAddColumnMigration(secureValueTable, &migrator.Column{ + Name: "owner_reference_api_version", + Type: migrator.DB_NVarchar, + Length: 253, // Limit enforced by K8s. + Nullable: true, + })) + + mg.AddMigration("add owner_reference_kind column to "+TableNameSecureValue, migrator.NewAddColumnMigration(secureValueTable, &migrator.Column{ + Name: "owner_reference_kind", + Type: migrator.DB_NVarchar, + Length: 253, // Limit enforced by K8s. + Nullable: true, + })) + + mg.AddMigration("add owner_reference_name column to "+TableNameSecureValue, migrator.NewAddColumnMigration(secureValueTable, &migrator.Column{ + Name: "owner_reference_name", + Type: migrator.DB_NVarchar, + Length: 253, // Limit enforced by K8s. + Nullable: true, + })) } diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index 03301cf12bc..dd53fa622b0 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -777,3 +777,512 @@ The dual writer system provides metrics for monitoring: - `dual_writer_mode_transitions_total`: Counter of mode transitions Use these metrics to monitor the health of your migration and identify any issues with the dual writer system. + +--- + +## Unified Search System + +The Unified Search system provides a scalable, distributed search capability for Grafana's Unified Storage. It uses a ring-based architecture to distribute search requests across multiple search server instances, with namespace-based sharding for optimal performance and data distribution. + +### System Architecture + +The search system provides both unified and legacy search capabilities, with routing based on dual writer mode configuration: + +```mermaid +graph TB + subgraph "Request Sources" + A[Grafana UI
User Search] + B[Dashboard Service
Resource Searches] + C[Folder Service
Resource Searches] + D[Alerting Service
Resource Searches] + E[Provisioning Service
Resource Searches] + F[API Endpoints
Search Operations] + end + + subgraph "API Gateway Layer" + G[Grafana API Server
Search Endpoint] + H[Search Client
Dual Writer Aware] + end + + subgraph "Routing Decision" + I{Dual Writer Mode
Check} + end + + subgraph "Unified Search Path (Mode 3+)" + J[Search Distributor
Ring-based Routing] + K[Ring
Consistent Hashing] + L[Search API Server 1
Namespace Sharding
+ Embedded Bleve Backend] + M[Search API Server 2
Namespace Sharding
+ Embedded Bleve Backend] + N[Search API Server 3
Namespace Sharding
+ Embedded Bleve Backend] + O[Unified Storage
K8s-style Resources] + end + + subgraph "Legacy Search Path (Mode 0-2)" + Q[Legacy Search Service
SQL-based Search] + R[Legacy Storage
Traditional Tables] + S[Shadow Traffic
Mode 1-2 + Flag Enabled] + end + + A --> G + B --> H + C --> H + D --> H + E --> H + F --> G + G --> H + H --> I + + I -->|Mode 3+| J + I -->|Mode 0-2| Q + + J --> K + K --> L + K --> M + K --> N + L -.->|Just-in-Time
Indexing| O + M -.->|Just-in-Time
Indexing| O + N -.->|Just-in-Time
Indexing| O + + Q --> R + Q -.->|Shadow Traffic
Mode 1-2 + Flag| S + S -.-> J +``` + +### Search Backend Routing + +The search client routes requests based on the dual writer mode configuration for each resource type: + +#### Dual Writer Mode → Backend Routing +- **Mode 0-2**: Route to **Legacy Search** + - Mode 1-2: Shadow traffic to Unified Search (if `unifiedStorageSearchDualReaderEnabled` is enabled) + - Mode 0: No shadow traffic +- **Mode 3+**: Route to **Unified Search** + - No shadow traffic needed (unified is primary) + +### Feature Flags + +Unified Search requires several feature flags to be enabled depending on the desired functionality: + +#### Prerequisites (Required for Unified Storage) + +| Feature Flag | Purpose | Stage | Required For | +|--------------|---------|-------|--------------| +| `grafanaAPIServerWithExperimentalAPIs` | Allow experimental API groups | Development | Access to v0alpha1 APIs (including search) | + +#### Unified Search Specific Flags + +| Feature Flag | Purpose | Stage | Required For | +|--------------|---------|-------|--------------| +| `unifiedStorageSearch` | Core search functionality | Experimental | Search API servers, indexing | +| `unifiedStorageSearchUI` | Frontend search interface | Experimental | Grafana UI search | +| `unifiedStorageSearchPermissionFiltering` | User permission filtering | GA | Access control in search results | +| `unifiedStorageSearchSprinkles` | Usage insights integration | Experimental | Dashboard usage sorting (Enterprise) | +| `unifiedStorageSearchDualReaderEnabled` | Shadow traffic to unified search | Experimental | Shadow traffic during migration | + +#### Basic Configuration +```ini +[feature_toggles] +; Prerequisites for unified storage (required) +grafanaAPIServerWithExperimentalAPIs = true + +; Core search functionality (required) +unifiedStorageSearch = true + +; Enable search UI (required for frontend) +unifiedStorageSearchUI = true + +; Enable permission filtering (recommended) +unifiedStorageSearchPermissionFiltering = true + +; Enable shadow traffic during migration (optional) +unifiedStorageSearchDualReaderEnabled = true + +; Enable usage insights sorting (Enterprise only) +unifiedStorageSearchSprinkles = true +``` + +### Request Flow Diagrams + +#### Search Request Flow with Dual Writer Mode Routing + +Search requests originate from multiple sources, and the search client routes based on dual writer mode configuration: + +```mermaid +flowchart TD + A[Search Request] --> B{Dual Writer Mode} + B -->|Mode 3+| C[Unified Search] + B -->|Mode 0-2| D[Legacy Search] + C --> E[Return Results] + D --> E +``` + +#### Search Request Flow with Shadow Traffic + +When `unifiedStorageSearchDualReaderEnabled` is enabled and resource is in dual writer Mode 1-2 (legacy primary), shadow traffic is generated: + +```mermaid +flowchart TD + A[Search Request] --> B{Shadow Traffic Enabled?} + B -->|Yes| C[Primary: Legacy Search] + B -->|No| D[Single Search Path] + C --> E[Background: Unified Search] + C --> F[Return Legacy Results] + E --> G[Log Results for Comparison] + D --> H[Return Results] +``` + +### Distributor Architecture + +The Search Distributor acts as a smart proxy that routes search requests to the appropriate search API server based on namespace hashing: + +```mermaid +flowchart TD + A[Incoming Search Request] --> B[Hash Namespace] + B --> C[Select Random Instance] + C --> D[Proxy Request] + D --> E[Return Response] +``` + +#### Key Features: +- **Namespace-based routing**: Each request is routed based on the target namespace +- **Load balancing**: Random selection among available replicas for the namespace +- **Health awareness**: Only routes to `ACTIVE` ring instances +- **Connection pooling**: Reuses gRPC connections for efficiency +- **Proxy headers**: Adds metadata for debugging and tracing + +### Ring Architecture + +The hash ring provides consistent, distributed assignment of namespaces to search API servers: + +```mermaid +flowchart TD + A[Namespace] --> B[Hash Function] + B --> C[Ring Position] + C --> D[Assigned Instance] + D --> E[Search Processing] +``` + +#### Ring Properties: +- **Consistent hashing**: Uses FNV32 hash function for namespace distribution +- **128 tokens per instance**: Provides good distribution across the ring +- **Replication factor**: Configurable redundancy (default based on cluster size) +- **State management**: Instances transition through JOINING → ACTIVE → LEAVING +- **Automatic rebalancing**: Ring adjusts when instances join/leave + +### Namespace-Based Sharding + +Unified Search uses namespace-based sharding to distribute search indexes across multiple search API servers: + +```mermaid +flowchart LR + A[Namespaces] --> B[Hash Ring] + B --> C[Search Server 1] + B --> D[Search Server 2] + B --> E[Search Server 3] + C --> F[Indexes for Assigned Namespaces] + D --> G[Indexes for Assigned Namespaces] + E --> H[Indexes for Assigned Namespaces] +``` + +#### Sharding Benefits: +1. **Horizontal scalability**: Add more search servers to handle more namespaces +2. **Resource isolation**: Each namespace's index is independent +3. **Parallel processing**: Multiple searches can run simultaneously across different servers +4. **Fault tolerance**: Namespace availability depends only on its assigned server(s) + +#### Sharding Algorithm: +```go +func getSearchServer(namespace string) string { + hash := fnv.New32a() + hash.Write([]byte(namespace)) + + // Get replication set from ring + replicationSet := ring.GetWithOptions( + hash.Sum32(), + searchRingRead, + ring.WithReplicationFactor(ring.ReplicationFactor()) + ) + + // Random load balancing within replication set + instance := replicationSet.Instances[rand.Intn(len(replicationSet.Instances))] + return instance.Id +} +``` + +### Search Index Management + +Each search API server contains an embedded Bleve search engine that manages indexes for its assigned namespaces: + +```mermaid +flowchart TD + A[Search Request] --> B{Index Ready?} + B -->|Yes| C[Query Index] + B -->|No| D[Build Index] + D --> E[Fetch Resources] + E --> F[Create Index] + F --> C + C --> G[Return Results] + + H[Resource Changes] --> I[Update Index] + I --> F +``` + +#### Index Architecture Details: + +**Embedded Bleve Backend**: Each Search API Server contains its own Bleve search engine instance, not a shared external service. + +**Just-in-Time Indexing**: When a search request arrives for a namespace that doesn't have an index (or has an outdated index): +1. The Search API Server fetches all resources for that namespace from Unified Storage +2. Builds search documents in memory +3. Creates either a memory-based or disk-based Bleve index depending on size +4. Executes the search query against the newly built index +5. Returns results to the user + +**Index Storage Strategy**: +- **Memory indexes**: For small datasets (< `index_file_threshold` documents) +- **Disk indexes**: For large datasets (≥ `index_file_threshold` documents) +- Indexes are stored per Search API Server instance, not globally shared + +**Background Updates**: In addition to just-in-time indexing, Search API Servers also maintain indexes through background watch events for incremental updates. + +#### Index Configuration: +```ini +[unified_storage] +; Path for disk-based search indexes +index_path = /var/lib/grafana/unified-search/bleve + +; Threshold for file-based vs memory indexes +index_file_threshold = 1000 + +; Maximum batch size for indexing +index_max_batch_size = 100 + +; Number of worker threads for indexing +index_workers = 4 + +; Cache TTL for indexes +index_cache_ttl = 1h + +; Periodic rebuild interval (for usage insights) +index_rebuild_interval = 24h + +; Minimum resource count required to build an index (default: 1) +; If a namespace has fewer resources than this threshold, no index will be created +index_min_count = 1 + +; Maximum resource count before creating an empty index (default: 0 = no limit) +; When exceeded, creates an empty index instead of indexing all resources for performance +index_max_count = 0 +``` + +### Search Request Sources + +Unified Search serves multiple types of consumers within the Grafana ecosystem: + +#### 1. User-Initiated Searches +- **Source**: Grafana UI search interface +- **Purpose**: Interactive dashboard and folder discovery +- **Characteristics**: Real-time, user-facing, latency-sensitive +- **Endpoint**: `/api/v1/search` (legacy search UI) or `/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search` (when `unifiedStorageSearchUI` is enabled) + +#### 2. Internal Service Searches + +Internal services use different search backends depending on dual writer mode configuration: + +- **Dashboard Service**: + - Find related dashboards based on tags, folders, or content + - Discover dashboards for playlist creation + - Validate dashboard references during operations + - **Backend**: Depends on dashboard dual writer mode (Legacy for Mode 0-2, Unified for Mode 3+) + +- **Folder Service**: + - Retrieve folder contents and nested structures + - Resolve folder hierarchy relationships + - Check folder permissions and accessibility + - **Backend**: Depends on folder dual writer mode (Legacy for Mode 0-2, Unified for Mode 3+) + +- **Alerting Service**: + - Discover dashboards and panels for alert rule creation + - Find existing alert rules across namespaces + - Resolve dashboard/panel references in alert definitions + - **Backend**: Mixed - dashboard searches use dashboard dual writer mode, alert rule searches typically use legacy + +- **Provisioning Service**: + - Check for existing resources before provisioning + - Validate resource uniqueness and naming conflicts + - Discover resources for bulk operations + - **Backend**: Depends on each resource type's dual writer mode configuration + +- **API Services**: + - Backend support for various API endpoints + - Resource validation and dependency checking + - **Backend**: Routes based on resource type's dual writer mode + +#### 3. Search Operation Types + +Unified Search supports multiple types of search operations: + +##### Resource Search +- **Purpose**: Find resources (dashboards, folders, etc.) by content +- **Endpoint**: `/api/v1/search` (legacy) or `/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search` (when `unifiedStorageSearchUI` is enabled) +- **Additional endpoint**: `/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search/sortable` for retrieving sortable fields +- **Features**: Full-text search, filtering, sorting + +**Sortable Fields:** + +The `/search/sortable` endpoint currently returns a limited static list: +```json +{ + "fields": [ + {"field": "title", "display": "Title (A-Z)", "type": "string"}, + {"field": "-title", "display": "Title (Z-A)", "type": "string"} + ] +} +``` + +However, the search backend actually supports sorting by many more fields: + +**Standard Fields:** +- `title` - Resource display name (uses `title_phrase` for exact sorting) +- `name` - Kubernetes resource name +- `description` - Resource description +- `folder` - Parent folder name +- `created` - Creation timestamp (int64) +- `updated` - Last update timestamp (int64) +- `createdBy` - Creator user ID +- `updatedBy` - Last updater user ID +- `tags` - Resource tags (array) +- `rv` - Resource version (int64) + +**Dashboard-Specific Fields** (require `fields.` prefix): +- `fields.schema_version` - Dashboard schema version +- `fields.link_count` - Number of dashboard links +- `fields.panel_types` - Panel types used in dashboard +- `fields.ds_types` - Data source types used +- `fields.transformation` - Transformations used + +**Usage Insights Fields** (Enterprise only, require `fields.` prefix): +- `fields.views_total` - Total dashboard views +- `fields.views_last_1_days` / `fields.views_last_7_days` / `fields.views_last_30_days` - Recent views +- `fields.views_today` - Today's views +- `fields.queries_total` - Total queries executed +- `fields.queries_last_1_days` / `fields.queries_last_7_days` / `fields.queries_last_30_days` - Recent queries +- `fields.queries_today` - Today's queries +- `fields.errors_total` - Total errors +- `fields.errors_last_1_days` / `fields.errors_last_7_days` / `fields.errors_last_30_days` - Recent errors +- `fields.errors_today` - Today's errors + +**Usage Examples:** +```bash +# Sort by title (ascending) +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?sortBy=title + +# Sort by creation date (descending) +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?sortBy=-created + +# Sort by usage insights (Enterprise) +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?sortBy=-fields.views_total +``` + +*Note: There's currently a discrepancy between the limited fields exposed by `/search/sortable` and the full range of fields actually supported by the search backend.* + +##### Federated Search +- **Purpose**: Search across multiple resource types simultaneously +- **Features**: Cross-resource queries, unified result ranking, combined sorting and faceting +- **Implementation**: Uses Bleve IndexAlias to combine multiple indexes for unified searching +- **Default behavior**: When no type is specified, automatically federates dashboards and folders + +**How Federated Search Works:** + +Federated search is implemented using Bleve's IndexAlias feature, which allows searching across multiple indexes as if they were a single unified index. This enables: + +1. **Cross-resource queries**: Search for content across dashboards, folders, and other resource types +2. **Unified sorting**: Results from different resource types are merged and sorted together +3. **Combined faceting**: Aggregate facet statistics across all federated resource types +4. **Permission filtering**: Respects user permissions for each resource type independently + +**API Usage Examples:** + +**1. Default Federation (Dashboards + Folders):** +```bash +# When no type is specified, automatically searches dashboards and folders +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?query=my-search +``` + +**2. Single Resource Type Search:** +```bash +# Search only folders (despite the "dashboard" API group, type parameter controls what's searched) +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?type=folders&query=my-search + +# Search only dashboards +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?type=dashboards&query=my-search +``` + +**3. Explicit Two-Type Federation:** +```bash +# Search dashboards (primary) with folders federated +GET /apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search?type=dashboards&type=folders&query=my-search +``` + +**4. Protocol Buffer Request Structure:** +```protobuf +message ResourceSearchRequest { + ListOptions options = 1; // Primary resource type to search + repeated ResourceKey federated = 2; // Additional resource types to federate + string query = 3; // Search query applied across all types + // ... other fields +} +``` + +**Example gRPC/Protocol Buffer Usage:** +```go +searchRequest := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, // Primary: search dashboards + }, + Federated: []*resourcepb.ResourceKey{ + folderKey, // Also search folders + }, + Query: "monitoring", + Limit: 50, + SortBy: []*resourcepb.ResourceSearchRequest_Sort{ + {Field: "title", Desc: false}, // Sort combined results by title + }, +} +``` + +**5. Unified Results:** +Federated search returns a single result set containing resources from all specified types, with: +- **Unified ranking**: All results scored and ranked together +- **Cross-type sorting**: Resources from different types sorted by common fields (title, tags, etc.) +- **Resource type identification**: Each result includes metadata indicating its resource type +- **Permission-aware filtering**: Only returns resources the user has permission to see + +**Limitations:** +- Federation only works across resource types with **common fields** (title, tags, folder, etc.) +- All federated indexes must be of the same search backend type (currently Bleve) +- Currently supports up to 2 resource types in federation via the API endpoint +- **Architectural note**: The search endpoint is under `dashboard.grafana.app` but can search any resource type via the `type` parameter - this is a design choice where the "dashboard search" has evolved into a generic search endpoint + +##### Managed Objects +- **Purpose**: Administrative queries for resource management +- **Operations**: Count, list, statistics + +##### Stats and Monitoring +- **Purpose**: Index health and performance metrics +- **Metrics**: Document counts, index sizes, search latency + + +### Monitoring and Observability + +Key metrics for monitoring Unified Search: + +- `unified_search_requests_total`: Search request counts by type and status +- `unified_search_request_duration_seconds`: Search request latency +- `unified_search_index_size_bytes`: Size of search indexes +- `unified_search_documents_total`: Number of indexed documents +- `unified_search_indexing_duration_seconds`: Time to build/update indexes +- `unified_search_shadow_requests_total`: Shadow traffic request counts +- `unified_search_ring_members`: Number of active search server instances + + diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go index 73a18d4fabe..3f130eb0b1b 100644 --- a/pkg/storage/unified/resource/bleve_index_metrics.go +++ b/pkg/storage/unified/resource/bleve_index_metrics.go @@ -9,11 +9,14 @@ import ( ) type BleveIndexMetrics struct { - IndexLatency *prometheus.HistogramVec - IndexSize prometheus.Gauge - IndexedKinds *prometheus.GaugeVec - IndexCreationTime *prometheus.HistogramVec - OpenIndexes *prometheus.GaugeVec + IndexLatency *prometheus.HistogramVec + IndexSize prometheus.Gauge + IndexedKinds *prometheus.GaugeVec + IndexCreationTime *prometheus.HistogramVec + OpenIndexes *prometheus.GaugeVec + IndexBuilds *prometheus.CounterVec + IndexBuildFailures prometheus.Counter + IndexBuildSkipped prometheus.Counter } var IndexCreationBuckets = []float64{1, 5, 10, 25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000} @@ -37,8 +40,8 @@ func ProvideIndexMetrics(reg prometheus.Registerer) *BleveIndexMetrics { Help: "Number of indexed documents by kind", }, []string{"kind"}), IndexCreationTime: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ - Name: "index_server_index_creation_time_seconds", - Help: "Time (in seconds) it takes until index is created", + Name: "index_server_index_build_time_seconds", + Help: "Time it takes to successfully build an index. Failed or skipped builds are not counted.", Buckets: IndexCreationBuckets, NativeHistogramBucketFactor: 1.1, // enable native histograms NativeHistogramMaxBucketNumber: 160, @@ -48,11 +51,22 @@ func ProvideIndexMetrics(reg prometheus.Registerer) *BleveIndexMetrics { Name: "index_server_open_indexes", Help: "Number of open indexes per storage type. An open index corresponds to single resource group.", }, []string{"index_storage"}), // index_storage is either "file" or "memory" + IndexBuilds: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Name: "index_server_index_build_total", + Help: "Number of times index build was attempted due to specific reason", + }, []string{"reason"}), + IndexBuildFailures: promauto.With(reg).NewCounter(prometheus.CounterOpts{ + Name: "index_server_index_build_failures_total", + Help: "Number of times index build failed", + }), + IndexBuildSkipped: promauto.With(reg).NewCounter(prometheus.CounterOpts{ + Name: "index_server_index_build_skipped_total", + Help: "Number of times index build has been skipped due to existing valid index being found on disk", + }), } // Initialize labels. m.OpenIndexes.WithLabelValues("file").Set(0) m.OpenIndexes.WithLabelValues("memory").Set(0) - return m } diff --git a/pkg/storage/unified/resource/bulk.go b/pkg/storage/unified/resource/bulk.go index f1dbb3131c2..c3f2e61e290 100644 --- a/pkg/storage/unified/resource/bulk.go +++ b/pkg/storage/unified/resource/bulk.go @@ -244,7 +244,7 @@ func (s *server) BulkProcess(stream resourcepb.BulkStore_BulkProcessServer) erro Namespace: summary.Namespace, Group: summary.Group, Resource: summary.Resource, - }, summary.Count, summary.ResourceVersion) + }, summary.Count, summary.ResourceVersion, "rebuildAfterBatchLoad") if err != nil { s.log.Warn("error building search index after batch load", "err", err) rsp.Error = &resourcepb.ErrorResult{ diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index bda857993ba..4752fa35cec 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -90,7 +90,7 @@ type SearchBackend interface { // Depending on the size, the backend may choose different options (eg: memory vs disk). // The last known resource version can be used to detect that nothing has changed, and existing on-disk index can be reused. // The builder will write all documents before returning. - BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, nonStandardFields SearchableDocumentFields, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) + BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, nonStandardFields SearchableDocumentFields, indexBuildReason string, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) // TotalDocs returns the total number of documents across all indexes. TotalDocs() int64 @@ -196,7 +196,7 @@ func (s *searchSupport) ListManagedObjects(ctx context.Context, req *resourcepb. Namespace: req.Namespace, Group: info.Group, Resource: info.Resource, - }) + }, "listManagedObjects") if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -237,7 +237,7 @@ func (s *searchSupport) CountManagedObjects(ctx context.Context, req *resourcepb Namespace: req.Namespace, Group: info.Group, Resource: info.Resource, - }) + }, "countManagedObjects") if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -282,7 +282,7 @@ func (s *searchSupport) Search(ctx context.Context, req *resourcepb.ResourceSear Namespace: req.Options.Key.Namespace, Resource: req.Options.Key.Resource, } - idx, err := s.getOrCreateIndex(ctx, nsr) + idx, err := s.getOrCreateIndex(ctx, nsr, "search") if err != nil { return &resourcepb.ResourceSearchResponse{ Error: AsErrorResult(err), @@ -294,7 +294,7 @@ func (s *searchSupport) Search(ctx context.Context, req *resourcepb.ResourceSear for i, f := range req.Federated { nsr.Group = f.Group nsr.Resource = f.Resource - federate[i], err = s.getOrCreateIndex(ctx, nsr) + federate[i], err = s.getOrCreateIndex(ctx, nsr, "federatedSearch") if err != nil { return &resourcepb.ResourceSearchResponse{ Error: AsErrorResult(err), @@ -323,7 +323,7 @@ func (s *searchSupport) GetStats(ctx context.Context, req *resourcepb.ResourceSt Namespace: req.Namespace, Group: parts[0], Resource: parts[1], - }) + }, "getStats") if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -367,7 +367,7 @@ func (s *searchSupport) GetStats(ctx context.Context, req *resourcepb.ResourceSt Namespace: req.Namespace, Group: stat.Group, Resource: stat.Resource, - }) + }, "getStats") if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -449,8 +449,12 @@ func (s *searchSupport) buildIndexes(ctx context.Context, rebuild bool) (int, er return err } - s.log.Debug("building index", "namespace", info.Namespace, "group", info.Group, "resource", info.Resource) - _, _, err := s.build(ctx, info.NamespacedResource, info.Count, info.ResourceVersion) + s.log.Debug("building index", "namespace", info.Namespace, "group", info.Group, "resource", info.Resource, "rebuild", rebuild) + reason := "init" + if rebuild { + reason = "rebuild" + } + _, _, err := s.build(ctx, info.NamespacedResource, info.Count, info.ResourceVersion, reason) return err }) } @@ -504,9 +508,6 @@ func (s *searchSupport) init(ctx context.Context) error { end := time.Now().Unix() s.log.Info("search index initialized", "duration_secs", end-start, "total_docs", s.search.TotalDocs()) - if s.indexMetrics != nil { - s.indexMetrics.IndexCreationTime.WithLabelValues().Observe(float64(end - start)) - } return nil } @@ -537,7 +538,7 @@ func (s *searchSupport) dispatchEvent(ctx context.Context, evt *WrittenEvent) { Group: evt.Key.Group, Resource: evt.Key.Resource, } - index, err := s.getOrCreateIndex(ctx, nsr) + index, err := s.getOrCreateIndex(ctx, nsr, "dispatchEvent") if err != nil { s.log.Warn("error getting index for watch event", "error", err) span.RecordError(err) @@ -622,15 +623,10 @@ func (s *searchSupport) rebuildDashboardIndexes(ctx context.Context) error { "duration", duration, "rebuilt_indexes", totalBatchesIndexed, "total_docs", s.search.TotalDocs()) - - if s.indexMetrics != nil { - s.indexMetrics.IndexCreationTime.WithLabelValues().Observe(duration.Seconds()) - } - return nil } -func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { +func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedResource, reason string) (ResourceIndex, error) { if s == nil || s.search == nil { return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)") } @@ -648,6 +644,10 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso } ch := s.buildIndex.DoChan(key.String(), func() (interface{}, error) { + // We want to finish building of the index even if original context is canceled. + // We reuse original context without cancel to keep the tracing spans correct. + ctx := context.WithoutCancel(ctx) + // Recheck if some other goroutine managed to build an index in the meantime. // (That is, it finished running this function and stored the index into the cache) idx, err := s.search.GetIndex(ctx, key) @@ -672,7 +672,7 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso } } - idx, _, err = s.build(ctx, key, size, rv) + idx, _, err = s.build(ctx, key, size, rv, reason) if err != nil { return nil, fmt.Errorf("error building search index, %w", err) } @@ -693,10 +693,18 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso } } -func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (ResourceIndex, int64, error) { +func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64, indexBuildReason string) (ResourceIndex, int64, error) { ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build") defer span.End() + span.SetAttributes( + attribute.String("namespace", nsr.Namespace), + attribute.String("group", nsr.Group), + attribute.String("resource", nsr.Resource), + attribute.Int64("size", size), + attribute.Int64("rv", rv), + ) + logger := s.log.With("namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource) builder, err := s.builders.get(ctx, nsr) @@ -705,7 +713,10 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size } fields := s.builders.GetFields(nsr) - index, err := s.search.BuildIndex(ctx, nsr, size, rv, fields, func(index ResourceIndex) (int64, error) { + index, err := s.search.BuildIndex(ctx, nsr, size, rv, fields, indexBuildReason, func(index ResourceIndex) (int64, error) { + span := trace.SpanFromContext(ctx) + span.AddEvent("building index", trace.WithAttributes(attribute.Int64("size", size), attribute.Int64("rv", rv), attribute.String("reason", indexBuildReason))) + rv, err = s.storage.ListIterator(ctx, &resourcepb.ListRequest{ Limit: 1000000000000, // big number Options: &resourcepb.ListOptions{ @@ -734,9 +745,11 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size Name: iter.Name(), } + span.AddEvent("building document", trace.WithAttributes(attribute.String("name", iter.Name()))) // Convert it to an indexable document doc, err := builder.BuildDocument(ctx, key, iter.ResourceVersion(), iter.Value()) if err != nil { + span.RecordError(err) logger.Error("error building search document", "key", SearchID(key), "err", err) continue } @@ -749,6 +762,7 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size // When we reach the batch size, perform bulk index and reset the batch. if len(items) >= maxBatchSize { + span.AddEvent("bulk indexing", trace.WithAttributes(attribute.Int("count", len(items)))) if err = index.BulkIndex(&BulkIndexRequest{ Items: items, }); err != nil { @@ -762,6 +776,7 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size // Index any remaining items in the final batch. if len(items) > 0 { + span.AddEvent("bulk indexing", trace.WithAttributes(attribute.Int("count", len(items)))) if err = index.BulkIndex(&BulkIndexRequest{ Items: items, }); err != nil { @@ -799,7 +814,7 @@ func (s *searchSupport) buildEmptyIndex(ctx context.Context, nsr NamespacedResou s.log.Debug("Building empty index", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "rv", rv) // Build an empty index by passing a builder function that doesn't add any documents - return s.search.BuildIndex(ctx, nsr, 0, rv, fields, func(index ResourceIndex) (int64, error) { + return s.search.BuildIndex(ctx, nsr, 0, rv, fields, "empty", func(index ResourceIndex) (int64, error) { // Return the resource version without adding any documents to the index return rv, nil }) diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index 07a9a464ae1..677e24b8b8b 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -121,7 +121,7 @@ func (m *mockSearchBackend) GetIndex(ctx context.Context, key NamespacedResource return nil, nil } -func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) { +func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, reason string, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) { index := &MockResourceIndex{} index.On("BulkIndex", mock.Anything).Return(nil).Maybe() index.On("DocCount", mock.Anything, mock.Anything).Return(int64(0), nil).Maybe() @@ -317,7 +317,7 @@ func TestSearchGetOrCreateIndex(t *testing.T) { go func() { defer wg.Done() <-start - _, _ = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}) + _, _ = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "test") }() } @@ -340,7 +340,7 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { {NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111}, }, } - search := &slowSearchBackend{ + search := &slowSearchBackendWithCache{ mockSearchBackend: mockSearchBackend{}, } supplier := &TestDocumentBuilderSupplier{ @@ -362,10 +362,12 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { require.NoError(t, err) require.NotNil(t, support) + key := NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"} + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) defer cancel() - _, err = support.getOrCreateIndex(ctx, NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}) + _, err = support.getOrCreateIndex(ctx, key, "test") // Make sure we get context deadline error require.ErrorIs(t, err, context.DeadlineExceeded) @@ -373,16 +375,53 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { search.wg.Wait() require.NotEmpty(t, search.buildIndexCalls) + + // Wait until new index is put into cache. + require.Eventually(t, func() bool { + idx, err := support.search.GetIndex(ctx, key) + return err == nil && idx != nil + }, 1*time.Second, 100*time.Millisecond, "Indexing finishes despite context cancellation") + + // Second call to getOrCreateIndex returns index immediately, even if context is canceled, as the index is now ready and cached. + _, err = support.getOrCreateIndex(ctx, key, "test") + require.NoError(t, err) } -type slowSearchBackend struct { +type slowSearchBackendWithCache struct { mockSearchBackend wg sync.WaitGroup + + mu sync.Mutex + cache map[NamespacedResource]ResourceIndex } -func (m *slowSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) { +func (m *slowSearchBackendWithCache) GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.cache[key], nil +} + +func (m *slowSearchBackendWithCache) BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, fields SearchableDocumentFields, reason string, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error) { m.wg.Add(1) defer m.wg.Done() + time.Sleep(1 * time.Second) - return m.mockSearchBackend.BuildIndex(ctx, key, size, resourceVersion, fields, builder) + + // Simulate erroring out when context is cancelled. + if ctx.Err() != nil { + return nil, ctx.Err() + } + idx, err := m.mockSearchBackend.BuildIndex(ctx, key, size, resourceVersion, fields, reason, builder) + if err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.cache == nil { + m.cache = make(map[NamespacedResource]ResourceIndex) + } + m.cache[key] = idx + return idx, nil } diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 141e02b4030..2626ab36f1d 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -23,6 +23,7 @@ import ( "github.com/blevesearch/bleve/v2/search/query" bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" index "github.com/blevesearch/bleve_index_api" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/selection" @@ -197,11 +198,21 @@ func (b *bleveBackend) BuildIndex( size int64, resourceVersion int64, fields resource.SearchableDocumentFields, + indexBuildReason string, builder func(index resource.ResourceIndex) (int64, error), ) (resource.ResourceIndex, error) { _, span := b.tracer.Start(ctx, tracingPrexfixBleve+"BuildIndex") defer span.End() + span.SetAttributes( + attribute.String("namespace", key.Namespace), + attribute.String("group", key.Group), + attribute.String("resource", key.Resource), + attribute.Int64("size", size), + attribute.Int64("rv", resourceVersion), + attribute.String("reason", indexBuildReason), + ) + mapper, err := GetBleveMappings(fields) if err != nil { return nil, err @@ -214,7 +225,7 @@ func (b *bleveBackend) BuildIndex( return nil, err } - logWithDetails := b.log.With("namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "size", size, "rv", resourceVersion) + logWithDetails := b.log.With("namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "size", size, "rv", resourceVersion, "reason", indexBuildReason) // Close the newly created/opened index by default. closeIndex := true @@ -306,14 +317,29 @@ func (b *bleveBackend) BuildIndex( } if build { + if b.indexMetrics != nil { + b.indexMetrics.IndexBuilds.WithLabelValues(indexBuildReason).Inc() + } + start := time.Now() _, err = builder(idx) if err != nil { logWithDetails.Error("Failed to build index", "err", err) + if b.indexMetrics != nil { + b.indexMetrics.IndexBuildFailures.Inc() + } return nil, fmt.Errorf("failed to build index: %w", err) } elapsed := time.Since(start) logWithDetails.Info("Finished building index", "elapsed", elapsed) + if b.indexMetrics != nil { + b.indexMetrics.IndexCreationTime.WithLabelValues().Observe(elapsed.Seconds()) + } + } else { + logWithDetails.Info("Skipping index build, using existing index") + if b.indexMetrics != nil { + b.indexMetrics.IndexBuildSkipped.Inc() + } } // Set expiration after building the index. Only expire in-memory indexes. diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index a795b4c0f8d..0680026e2af 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -553,7 +553,7 @@ func newTestDashboardsIndex(t TB, threshold int64, size int64, batchSize int64, Namespace: key.Namespace, Group: key.Group, Resource: key.Resource, - }, size, rv, info.Fields, writer) + }, size, rv, info.Fields, "test", writer) require.NoError(t, err) return index, tmpdir diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index 5a4c16be2b3..821ce6de0c0 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -71,7 +71,7 @@ func TestBleveBackend(t *testing.T) { Namespace: key.Namespace, Group: key.Group, Resource: key.Resource, - }, 2, rv, info.Fields, func(index resource.ResourceIndex) (int64, error) { + }, 2, rv, info.Fields, "test", func(index resource.ResourceIndex) (int64, error) { err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -352,7 +352,7 @@ func TestBleveBackend(t *testing.T) { Namespace: key.Namespace, Group: key.Group, Resource: key.Resource, - }, 2, rv, fields, func(index resource.ResourceIndex) (int64, error) { + }, 2, rv, fields, "test", func(index resource.ResourceIndex) (int64, error) { err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -766,7 +766,7 @@ func TestBleveInMemoryIndexExpiration(t *testing.T) { Resource: "resource", } - builtIndex, err := backend.BuildIndex(context.Background(), ns, 1 /* below FileThreshold */, 100, nil, indexTestDocs(ns, 1)) + builtIndex, err := backend.BuildIndex(context.Background(), ns, 1 /* below FileThreshold */, 100, nil, "test", indexTestDocs(ns, 1)) require.NoError(t, err) // Wait for index expiration, which is 1ns @@ -798,7 +798,7 @@ func TestBleveFileIndexExpiration(t *testing.T) { } // size=100 is above FileThreshold, this will be file-based index - builtIndex, err := backend.BuildIndex(context.Background(), ns, 100, 100, nil, indexTestDocs(ns, 1)) + builtIndex, err := backend.BuildIndex(context.Background(), ns, 100, 100, nil, "test", indexTestDocs(ns, 1)) require.NoError(t, err) // Wait for index expiration, which is 1ns @@ -830,7 +830,7 @@ func TestFileIndexIsReusedOnSameSizeAndRV(t *testing.T) { tmpDir := t.TempDir() backend1, reg1 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - _, err := backend1.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, indexTestDocs(ns, 10)) + _, err := backend1.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 10)) require.NoError(t, err) // Verify one open index. @@ -853,7 +853,7 @@ func TestFileIndexIsReusedOnSameSizeAndRV(t *testing.T) { // We open new backend using same directory, and run indexing with same size (10) and RV (100). This should reuse existing index, and skip indexing. backend2, reg2 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, indexTestDocs(ns, 1000)) + idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, "test", indexTestDocs(ns, 1000)) require.NoError(t, err) // Verify that we're reusing existing index and there is only 10 documents in it, not 1000. @@ -879,13 +879,13 @@ func TestFileIndexIsNotReusedOnDifferentSize(t *testing.T) { tmpDir := t.TempDir() backend1, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, indexTestDocs(ns, 10)) + _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10)) require.NoError(t, err) backend1.closeAllIndexes() // We open new backend using same directory, but with different size. Index should be rebuilt. backend2, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err := backend2.BuildIndex(context.Background(), ns, 100, 100, nil, indexTestDocs(ns, 100)) + idx, err := backend2.BuildIndex(context.Background(), ns, 100, 100, nil, "test", indexTestDocs(ns, 100)) require.NoError(t, err) // Verify that index has updated number of documents. @@ -904,13 +904,13 @@ func TestFileIndexIsNotReusedOnDifferentRV(t *testing.T) { tmpDir := t.TempDir() backend1, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, indexTestDocs(ns, 10)) + _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10)) require.NoError(t, err) backend1.closeAllIndexes() // We open new backend using same directory, but with different RV. Index should be rebuilt. backend2, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) - idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 999999, nil, indexTestDocs(ns, 100)) + idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 999999, nil, "test", indexTestDocs(ns, 100)) require.NoError(t, err) // Verify that index has updated number of documents. @@ -942,7 +942,7 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { if testCase.firstInMemory { firstSize = 1 } - firstIndex, err := backend.BuildIndex(context.Background(), ns, int64(firstSize), 100, nil, indexTestDocs(ns, firstSize)) + firstIndex, err := backend.BuildIndex(context.Background(), ns, int64(firstSize), 100, nil, "test", indexTestDocs(ns, firstSize)) require.NoError(t, err) openInMemoryIndexes := 0 @@ -952,7 +952,7 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { secondSize = 1 openInMemoryIndexes = 1 } - secondIndex, err := backend.BuildIndex(context.Background(), ns, int64(secondSize), 100, nil, indexTestDocs(ns, secondSize)) + secondIndex, err := backend.BuildIndex(context.Background(), ns, int64(secondSize), 100, nil, "test", indexTestDocs(ns, secondSize)) require.NoError(t, err) // Verify that first and second index are different, and first one is now closed. @@ -1050,12 +1050,12 @@ func testBleveIndexWithFailures(t *testing.T, fileBased bool) { // size=100 is above FileThreshold (5), make it a file-based index. size = 100 } - _, err := backend.BuildIndex(context.Background(), ns, size, 100, nil, func(index resource.ResourceIndex) (int64, error) { + _, err := backend.BuildIndex(context.Background(), ns, size, 100, nil, "test", func(index resource.ResourceIndex) (int64, error) { return 0, fmt.Errorf("fail") }) require.Error(t, err) // Even though previous build of the index failed, new building of the index should work. - _, err = backend.BuildIndex(context.Background(), ns, size, 100, nil, indexTestDocs(ns, int(size))) + _, err = backend.BuildIndex(context.Background(), ns, size, 100, nil, "test", indexTestDocs(ns, int(size))) require.NoError(t, err) } diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index a23cf9469ea..7733de8ff91 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -10,9 +10,9 @@ import ( "time" "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana/pkg/util/sqlite" "github.com/jackc/pgx/v5/pgconn" "github.com/lib/pq" - "github.com/mattn/go-sqlite3" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" @@ -389,9 +389,8 @@ func (b *backend) create(ctx context.Context, event resource.WriteEvent) (int64, // IsRowAlreadyExistsError checks if the error is the result of the row inserted already existing. func IsRowAlreadyExistsError(err error) bool { - var sqlite sqlite3.Error - if errors.As(err, &sqlite) { - return sqlite.ExtendedCode == sqlite3.ErrConstraintUnique + if sqlite.IsUniqueConstraintViolation(err) { + return true } var pg *pgconn.PgError diff --git a/pkg/storage/unified/sql/backend_test.go b/pkg/storage/unified/sql/backend_test.go index 92989e8454a..534b393065e 100644 --- a/pkg/storage/unified/sql/backend_test.go +++ b/pkg/storage/unified/sql/backend_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" - "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -18,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/grafana/grafana/pkg/storage/unified/sql/test" + "github.com/grafana/grafana/pkg/util/sqlite" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -242,7 +242,7 @@ func TestBackend_create(t *testing.T) { ) b.SQLMock.ExpectCommit() b.SQLMock.ExpectBegin() - b.SQLMock.ExpectExec("insert resource").WillReturnError(sqlite3.Error{Code: sqlite3.ErrConstraint, ExtendedCode: sqlite3.ErrConstraintUnique}) + b.SQLMock.ExpectExec("insert resource").WillReturnError(sqlite.TestErrUniqueConstraintViolation) b.SQLMock.ExpectRollback() // First we insert the resource successfully. This is what the happy path test does as well. diff --git a/pkg/storage/unified/testing/benchmark.go b/pkg/storage/unified/testing/benchmark.go index 8503b1dd2bd..dcdf113c212 100644 --- a/pkg/storage/unified/testing/benchmark.go +++ b/pkg/storage/unified/testing/benchmark.go @@ -215,7 +215,7 @@ func runSearchBackendBenchmarkWriteThroughput(ctx context.Context, backend resou // Build initial index size := int64(10000) // force the index to be on disk - index, err := backend.BuildIndex(ctx, nr, size, 0, nil, func(index resource.ResourceIndex) (int64, error) { + index, err := backend.BuildIndex(ctx, nr, size, 0, nil, "benchmark", func(index resource.ResourceIndex) (int64, error) { return 0, nil }) if err != nil { diff --git a/pkg/storage/unified/testing/search_backend.go b/pkg/storage/unified/testing/search_backend.go index 57d887682c6..52af572b01a 100644 --- a/pkg/storage/unified/testing/search_backend.go +++ b/pkg/storage/unified/testing/search_backend.go @@ -64,7 +64,7 @@ func runTestSearchBackendBuildIndex(t *testing.T, backend resource.SearchBackend require.Nil(t, index) // Build the index - index, err = backend.BuildIndex(ctx, ns, 0, 0, nil, func(index resource.ResourceIndex) (int64, error) { + index, err = backend.BuildIndex(ctx, ns, 0, 0, nil, "test", func(index resource.ResourceIndex) (int64, error) { // Write a test document err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ @@ -111,7 +111,7 @@ func runTestResourceIndex(t *testing.T, backend resource.SearchBackend, nsPrefix } // Build initial index with some test documents - index, err := backend.BuildIndex(ctx, ns, 3, 0, nil, func(index resource.ResourceIndex) (int64, error) { + index, err := backend.BuildIndex(ctx, ns, 3, 0, nil, "test", func(index resource.ResourceIndex) (int64, error) { err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -235,7 +235,7 @@ func runTestResourceIndex(t *testing.T, backend resource.SearchBackend, nsPrefix t.Run("Search by LibraryPanel reference", func(t *testing.T) { // Build index with dashboards that have LibraryPanel references - index, err := backend.BuildIndex(ctx, ns, 3, 0, nil, func(index resource.ResourceIndex) (int64, error) { + index, err := backend.BuildIndex(ctx, ns, 3, 0, nil, "test", func(index resource.ResourceIndex) (int64, error) { err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { diff --git a/pkg/tests/api/shorturl/short_url_test.go b/pkg/tests/api/shorturl/short_url_test.go index e598c36ea7a..a938b861281 100644 --- a/pkg/tests/api/shorturl/short_url_test.go +++ b/pkg/tests/api/shorturl/short_url_test.go @@ -7,8 +7,10 @@ import ( "fmt" "io" "net/http" + "net/url" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" @@ -31,10 +33,12 @@ func TestMain(m *testing.M) { func TestShortURL(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ AppModeProduction: true, + DisableAnonymous: true, }) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + // Test that the endpoint is accessible with authentication. username, password := "viewer", "viewer" createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleEditor), @@ -50,7 +54,7 @@ func TestShortURL(t *testing.T) { defer func() { _ = res.Body.Close() }() - require.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, http.StatusOK, res.StatusCode) bodyRaw, err := io.ReadAll(res.Body) require.NoError(t, err) @@ -67,8 +71,8 @@ func TestShortURL(t *testing.T) { defer func() { _ = res.Body.Close() }() - require.Equal(t, "http://localhost:3000/explore", res.Header.Get("Location")) - require.Equal(t, http.StatusFound, res.StatusCode) + assert.Equal(t, "http://localhost:3000/explore", res.Header.Get("Location")) + assert.Equal(t, http.StatusFound, res.StatusCode) // If the go-to does not exist, it should redirect to the home page and return 308. res, err = c.get("/goto/DoesNotExist") @@ -76,8 +80,28 @@ func TestShortURL(t *testing.T) { defer func() { _ = res.Body.Close() }() - require.Equal(t, "http://localhost:3000/", res.Header.Get("Location")) - require.Equal(t, http.StatusPermanentRedirect, res.StatusCode) + assert.Equal(t, "http://localhost:3000/", res.Header.Get("Location")) + assert.Equal(t, http.StatusPermanentRedirect, res.StatusCode) + + // Create a client that does not have authentication. + notLoggedInClient := client(grafanaListedAddr, "", "") + // Test that the short-urls endpoint is not accessible without authentication. + res, err = notLoggedInClient.post("/api/short-urls", bytes.NewReader([]byte(`{"path":"explore"}`))) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + defer func() { + _ = res.Body.Close() + }() + + // If the user is not logged in, it should redirect to the login page and return 302. + res, err = notLoggedInClient.get(fmt.Sprintf("/goto/%s", resParsed.UID)) + require.NoError(t, err) + defer func() { + _ = res.Body.Close() + }() + expectedRedirect := "/login?redirectTo=" + url.QueryEscape("/goto/"+resParsed.UID) + assert.Equal(t, expectedRedirect, res.Header.Get("Location")) + assert.Equal(t, http.StatusFound, res.StatusCode) } func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 2d5585b819f..03ca9342f31 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -112,11 +112,11 @@ func (h *provisioningTestHelper) AwaitJobSuccess(t *testing.T, ctx context.Conte require.NoError(t, err) require.NotNil(t, result) + errors := mustNestedStringSlice(result.Object, "status", "errors") + require.Empty(t, errors, "historic job '%s' has errors: %v", job.GetName(), errors) state := mustNestedString(result.Object, "status", "state") require.Equal(t, string(provisioning.JobStateSuccess), state, "historic job '%s' was not successful", job.GetName()) - errors := mustNestedStringSlice(result.Object, "status", "errors") - require.Empty(t, errors, "historic job '%s' has errors: %v", job.GetName(), errors) }, time.Second*10, time.Millisecond*25) { // We also want to add the job details to the error when it fails. job, err := h.Jobs.Resource.Get(ctx, job.GetName(), metav1.GetOptions{}) @@ -163,6 +163,50 @@ func (h *provisioningTestHelper) AwaitJobs(t *testing.T, repoName string) { } } +// AwaitJobsWithStates waits for all jobs for a repository to complete and accepts multiple valid end states +func (h *provisioningTestHelper) AwaitJobsWithStates(t *testing.T, repoName string, acceptedStates []string) { + t.Helper() + + // First, we wait for all jobs for the repository to disappear (i.e. complete/fail). + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list, err := h.Jobs.Resource.List(context.Background(), metav1.ListOptions{}) + if assert.NoError(collect, err, "failed to list active jobs") { + for _, elem := range list.Items { + repo, _, err := unstructured.NestedString(elem.Object, "spec", "repository") + require.NoError(t, err) + if repo == repoName { + collect.Errorf("there are still remaining jobs for %s: %+v", repoName, elem) + return + } + } + } + }, time.Second*10, time.Millisecond*25, "job queue must be empty") + + // Then, as all jobs are now historic jobs, we make sure they are in an accepted state. + result, err := h.Repositories.Resource.Get(context.Background(), repoName, metav1.GetOptions{}, "jobs") + require.NoError(t, err, "failed to list historic jobs") + + list, err := result.ToList() + require.NoError(t, err, "results should be a list") + require.NotEmpty(t, list.Items, "expect at least one job") + + for _, elem := range list.Items { + require.Equal(t, repoName, elem.GetLabels()[jobs.LabelRepository], "should have repo label") + + state := mustNestedString(elem.Object, "status", "state") + + // Check if state is in accepted states + found := false + for _, acceptedState := range acceptedStates { + if state == acceptedState { + found = true + break + } + } + require.True(t, found, "job %s completed with unexpected state %s (expected one of %v): %+v", elem.GetName(), state, acceptedStates, elem.Object) + } +} + // RenderObject reads the filePath and renders it as a template with the given values. // The template is expected to be a YAML or JSON file. // diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index cdf210e2133..f9a5c40a14e 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -161,14 +161,21 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { // Viewer can see settings listing t.Run("viewer has access to list", func(t *testing.T) { settings := &provisioning.RepositoryViewList{} - rsp := helper.ViewerREST.Get(). - Namespace("default"). - Suffix("settings"). - Do(context.Background()) - require.NoError(t, rsp.Error()) - err := rsp.Into(settings) - require.NoError(t, err) - require.Len(t, settings.Items, len(inputFiles)) + // Wait for unified storage to make the data available + require.Eventually(t, func() bool { + rsp := helper.ViewerREST.Get(). + Namespace("default"). + Suffix("settings"). + Do(context.Background()) + if rsp.Error() != nil { + return false + } + err := rsp.Into(settings) + if err != nil { + return false + } + return len(settings.Items) == len(inputFiles) + }, time.Second*10, time.Millisecond*100, "Expected settings to have len(inputFiles) items") // FIXME: this should be an enterprise integration test if extensions.IsEnterprise { @@ -1825,8 +1832,10 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { // Verify dashboard still exists in Grafana with same content but may have updated path references helper.SyncAndWait(t, repo, nil) - _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) - require.NoError(t, err, "dashboard should still exist in Grafana after move") + require.Eventually(t, func() bool { + _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + return err == nil + }, 10*time.Second, 100*time.Millisecond, "dashboard should still exist in Grafana after move") // Using Eventually to account for potential delays in dashboards APIs. }) t.Run("move file to nested path without ref", func(t *testing.T) { @@ -2107,3 +2116,196 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { }, time.Second*10, time.Millisecond*100, "Expected move job to handle non-existent resource") }) } + +func TestIntegrationProvisioning_SecondRepositoryOnlyExportsNewDashboards(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // Create some unmanaged dashboards directly in Grafana first + dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create first dashboard") + dashboard1Name := dashboard1Obj.GetName() + + dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") + dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create second dashboard") + dashboard2Name := dashboard2Obj.GetName() + + // Create the first repository with sync enabled + const repo1 = "first-repository" + repo1Path := filepath.Join(helper.ProvisioningPath, repo1) + err = os.MkdirAll(repo1Path, 0750) + require.NoError(t, err, "should be able to create repository path") + + createBody1 := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo1, + "SyncEnabled": true, + "SyncTarget": "folder", + "Path": repo1Path, + }) + _, err = helper.Repositories.Resource.Create(ctx, createBody1, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create first repository") + + // Print file tree before export + printFileTree(t, helper.ProvisioningPath) + + // Initial export + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo1). + SubResource("jobs"). + SetHeader("Content-Type", "application/json"). + Body(asJSON(&provisioning.JobSpec{ + Push: &provisioning.ExportJobOptions{ + Folder: "", // export entire instance + Path: "", // no prefix necessary for testing + }, + })). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create export job for first repo") + helper.AwaitJobsWithStates(t, repo1, []string{"success"}) + // Wait for first repository to sync + helper.SyncAndWait(t, repo1, nil) + + printFileTree(t, helper.ProvisioningPath) + // Verify that the first repository has claimed ownership of the dashboards + managedDash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, managedDash1.GetAnnotations()[utils.AnnoKeyManagerIdentity], "dashboard1 should be managed by first repo") + + managedDash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, managedDash2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "dashboard2 should be managed by first repo") + + // Create second repository - enable sync and set different target + + const repo2 = "second-repository" + repo2Path := filepath.Join(helper.ProvisioningPath, repo2) + err = os.MkdirAll(repo2Path, 0750) + require.NoError(t, err, "should be able to create seconrd repository path") + + printFileTree(t, helper.ProvisioningPath) + + createBody2 := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo2, + "SyncEnabled": true, + "SyncTarget": "folder", + "Path": repo2Path, + }) + + _, err = helper.Repositories.Resource.Create(ctx, createBody2, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create second repository") + + // Wait for second repository to sync + helper.SyncAndWait(t, repo2, nil) + + // Validate that folders for both repositories exist + folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "should be able to list folders") + + var repo1FolderFound, repo2FolderFound bool + for _, folder := range folders.Items { + if folder.GetName() == repo1 { + repo1FolderFound = true + } + if folder.GetName() == repo2 { + repo2FolderFound = true + } + } + require.True(t, repo1FolderFound, "folder for first repository %s should exist after sync", repo1) + require.True(t, repo2FolderFound, "folder for second repository %s should exist after sync", repo2) + + // Create a third dashboard that won't be claimed by the first repo + dashboard3 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v0.yaml") + dashboard3Obj, err := helper.DashboardsV0.Resource.Create(ctx, dashboard3, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create third dashboard") + dashboard3Name := dashboard3Obj.GetName() + + // Verify dashboard3 is not managed by anyone initially + unmanagedDash3, err := helper.DashboardsV0.Resource.Get(ctx, dashboard3Name, metav1.GetOptions{}) + require.NoError(t, err) + manager, found := unmanagedDash3.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, !found || manager == "", "dashboard3 should not be managed initially") + + printFileTree(t, helper.ProvisioningPath) + // Count files in first repo before second export + files1Before, err := countFilesInDir(repo1Path) + require.NoError(t, err) + + // Export from second repository - this should only export the unmanaged dashboard3 + result = helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo2). + SubResource("jobs"). + SetHeader("Content-Type", "application/json"). + Body(asJSON(&provisioning.JobSpec{ + Push: &provisioning.ExportJobOptions{ + Folder: "", // export entire instance + Path: "", // no prefix necessary for testing + }, + })). + Do(ctx) + require.NoError(t, result.Error(), "should be able to create export job for second repo") + + // Wait for second repository export to complete + helper.AwaitJobsWithStates(t, repo2, []string{"success"}) + + // Wait for second repository to sync + helper.SyncAndWait(t, repo1, nil) + helper.SyncAndWait(t, repo2, nil) + + printFileTree(t, helper.ProvisioningPath) + files1After, err := countFilesInDir(repo1Path) + require.NoError(t, err) + + actualNewFiles := files1After - files1Before + require.Equal(t, 0, actualNewFiles, + "second repository should skip managed dashboards and had folder issues with unmanaged dashboard (expected %d new files, got %d)", + 0, actualNewFiles) + + // Verify files in the second repository + files2After, err := countFilesInDir(repo2Path) + require.NoError(t, err) + require.Equal(t, 1, files2After, + "second repository should only export the unmanaged dashboard (expected %d new files, got %d)", + 1, files2After) + + // Verify dashboard1 and dashboard2 are still managed by repo1 (unchanged) + stillManagedDash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, stillManagedDash1.GetAnnotations()[utils.AnnoKeyManagerIdentity], + "dashboard1 should still be managed by first repo") + + stillManagedDash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, stillManagedDash2.GetAnnotations()[utils.AnnoKeyManagerIdentity], + "dashboard2 should still be managed by first repo") + + // Verify dashboard3 is now managed by repo2 + stillManagedDash3, err := helper.DashboardsV0.Resource.Get(ctx, dashboard3Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo2, stillManagedDash3.GetAnnotations()[utils.AnnoKeyManagerIdentity], + "dashboard3 should now be managed by second repo") +} + +// Helper function to count files in a directory recursively +func countFilesInDir(rootPath string) (int, error) { + count := 0 + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + count++ + } + return nil + }) + return count, err +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index eb9b460c6b8..56f9f3000e2 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -13,6 +13,7 @@ import ( "time" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -85,6 +86,20 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes err = featuremgmt.InitOpenFeatureWithCfg(cfg) require.NoError(t, err) + + // Use proper database type based on the environment variable GRAFANA_TEST_DB in tests + testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType()) + require.NoError(t, err) + t.Cleanup(testDB.Cleanup) + + dbCfg := cfg.Raw.Section("database") + dbCfg.Key("type").SetValue(testDB.DriverName) + dbCfg.Key("host").SetValue(testDB.Host) + dbCfg.Key("port").SetValue(testDB.Port) + dbCfg.Key("user").SetValue(testDB.User) + dbCfg.Key("password").SetValue(testDB.Password) + dbCfg.Key("name").SetValue(testDB.Database) + env, err := server.InitializeForTest(t, t, cfg, serverOpts, apiServerOpts) require.NoError(t, err) diff --git a/pkg/tsdb/elasticsearch/healthcheck.go b/pkg/tsdb/elasticsearch/healthcheck.go index a3e04aafb6f..928945691de 100644 --- a/pkg/tsdb/elasticsearch/healthcheck.go +++ b/pkg/tsdb/elasticsearch/healthcheck.go @@ -191,11 +191,15 @@ func validateIndex(ctx context.Context, ds *es.DatasourceInfo) (message string, return "Failed to unmarshal field capabilities response", "error" } if fieldCaps["error"] != nil { - if errorMessage, ok := fieldCaps["error"].(map[string]any)["reason"].(string); ok { - return fmt.Sprintf("Error validating index: %s", errorMessage), "warning" - } else { + errorMap, ok := fieldCaps["error"].(map[string]any) + if !ok { return "Error validating index", "warning" } + errorMessage, ok := errorMap["reason"].(string) + if !ok { + return "Error validating index", "warning" + } + return fmt.Sprintf("Error validating index: %s", errorMessage), "warning" } fields, ok := fieldCaps["fields"].(map[string]any) diff --git a/pkg/tsdb/elasticsearch/healthcheck_test.go b/pkg/tsdb/elasticsearch/healthcheck_test.go index b3f6dc97c93..48fd00e8adc 100644 --- a/pkg/tsdb/elasticsearch/healthcheck_test.go +++ b/pkg/tsdb/elasticsearch/healthcheck_test.go @@ -58,6 +58,16 @@ func Test_validateIndex_Warning_ErrorValidatingIndex(t *testing.T) { assert.Equal(t, "Elasticsearch data source is healthy. Warning: Error validating index: index_not_found", res.Message) } +func Test_validateIndex_Warning_ErrorValidatingIndex2(t *testing.T) { + service := GetMockService(http.StatusOK, "200 OK", `{"status":"green"}`, `{"error":"not a map"}`) + res, _ := service.CheckHealth(mockedCfg, &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{}, + Headers: nil, + }) + assert.Equal(t, backend.HealthStatusOk, res.Status) + assert.Equal(t, "Elasticsearch data source is healthy. Warning: Error validating index", res.Message) +} + func Test_validateIndex_Warning_WrongTimestampType(t *testing.T) { service := GetMockService(http.StatusOK, "200 OK", `{"status":"green"}`, `{"fields":{"timestamp":{"float":{"metadata_field":true}}}}`) res, _ := service.CheckHealth(mockedCfg, &backend.CheckHealthRequest{ diff --git a/pkg/tsdb/influxdb/flux/flux.go b/pkg/tsdb/influxdb/flux/flux.go index cab5c0d1067..6643f064910 100644 --- a/pkg/tsdb/influxdb/flux/flux.go +++ b/pkg/tsdb/influxdb/flux/flux.go @@ -27,9 +27,8 @@ func Query(ctx context.Context, dsInfo *models.DatasourceInfo, tsdbQuery backend } defer r.client.Close() - timeRange := tsdbQuery.Queries[0].TimeRange for _, query := range tsdbQuery.Queries { - qm, err := getQueryModel(query, timeRange, dsInfo) + qm, err := getQueryModel(query, query.TimeRange, dsInfo) if err != nil { tRes.Responses[query.RefID] = backend.DataResponse{ Error: err, diff --git a/pkg/tsdb/jaeger/client.go b/pkg/tsdb/jaeger/client.go index 653c0463892..d858aa48f56 100644 --- a/pkg/tsdb/jaeger/client.go +++ b/pkg/tsdb/jaeger/client.go @@ -115,11 +115,15 @@ func (j *JaegerClient) Operations(s string) ([]string, error) { } func (j *JaegerClient) Search(query *JaegerQuery, start, end int64) ([]TraceResponse, error) { - jaegerURL, err := url.Parse(j.url) + u, err := url.JoinPath(j.url, "/api/traces") if err != nil { - return []TraceResponse{}, fmt.Errorf("failed to parse Jaeger URL: %w", err) + return []TraceResponse{}, backend.DownstreamError(fmt.Errorf("failed to join url path: %w", err)) + } + + jaegerURL, err := url.Parse(u) + if err != nil { + return []TraceResponse{}, backend.DownstreamError(fmt.Errorf("failed to parse Jaeger URL: %w", err)) } - jaegerURL.Path = "/api/traces" var queryTags string if query.Tags != "" { @@ -135,7 +139,7 @@ func (j *JaegerClient) Search(query *JaegerQuery, start, end int64) ([]TraceResp marshaledTags, err := json.Marshal(tagMap) if err != nil { - return []TraceResponse{}, fmt.Errorf("failed to convert tags to JSON: %w", err) + return []TraceResponse{}, backend.DownstreamError(fmt.Errorf("failed to convert tags to JSON: %w", err)) } queryTags = string(marshaledTags) diff --git a/pkg/tsdb/jaeger/client_test.go b/pkg/tsdb/jaeger/client_test.go index afabdc4bccc..eceb103ab82 100644 --- a/pkg/tsdb/jaeger/client_test.go +++ b/pkg/tsdb/jaeger/client_test.go @@ -186,6 +186,19 @@ func TestJaegerClient_Search(t *testing.T) { expectError bool expectedError error }{ + { + name: "Preserves base path in Jaeger URL", + query: &JaegerQuery{ + Service: "test-service", + }, + start: 1735689600000000, + end: 1738368000000000, + mockResponse: `{"data":[{"traceID":"test-trace-id"}]}`, + mockStatusCode: http.StatusOK, + expectedURL: "/abc/api/traces?end=1738368000000000&service=test-service&start=1735689600000000", + expectError: false, + expectedError: nil, + }, { name: "Successful search with all parameters", query: &JaegerQuery{ @@ -245,6 +258,11 @@ func TestJaegerClient_Search(t *testing.T) { settings := backend.DataSourceInstanceSettings{ URL: server.URL, } + + if tt.name == "Preserves base path in Jaeger URL" { + settings.URL = server.URL + "/abc" + } + client, err := New(server.Client(), log.NewNullLogger(), settings) assert.NoError(t, err) traces, err := client.Search(tt.query, tt.start, tt.end) diff --git a/pkg/util/sqlite/sqlite_cgo.go b/pkg/util/sqlite/sqlite_cgo.go new file mode 100644 index 00000000000..de18511741d --- /dev/null +++ b/pkg/util/sqlite/sqlite_cgo.go @@ -0,0 +1,46 @@ +//go:build cgo + +package sqlite + +import ( + "errors" + + "github.com/mattn/go-sqlite3" +) + +type Driver = sqlite3.SQLiteDriver + +// The errors below are used in tests to simulate specific SQLite errors. It's a temporary solution +// until we rewrite the tests not to depend on the sqlite3 package internals directly. +var ( + TestErrUniqueConstraintViolation = sqlite3.Error{Code: sqlite3.ErrConstraint, ExtendedCode: sqlite3.ErrConstraintUnique} + TestErrBusy = sqlite3.Error{Code: sqlite3.ErrBusy} + TestErrLocked = sqlite3.Error{Code: sqlite3.ErrLocked} +) + +func IsBusyOrLocked(err error) bool { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) { + return sqliteErr.Code == sqlite3.ErrLocked || sqliteErr.Code == sqlite3.ErrBusy + } + return false +} + +func IsUniqueConstraintViolation(err error) bool { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) { + return sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique || sqliteErr.ExtendedCode == sqlite3.ErrConstraintPrimaryKey + } + return false +} + +func ErrorMessage(err error) string { + if err == nil { + return "" + } + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) { + return sqliteErr.Error() + } + return err.Error() +} diff --git a/pkg/util/sqlite/sqlite_nocgo.go b/pkg/util/sqlite/sqlite_nocgo.go new file mode 100644 index 00000000000..e3b9a0429d3 --- /dev/null +++ b/pkg/util/sqlite/sqlite_nocgo.go @@ -0,0 +1,24 @@ +//go:build !cgo + +package sqlite + +import "modernc.org/sqlite" + +// +// FIXME (@zserge) +// +// This non-CGo "implementation" is merely a stub to make Grafana compile without CGo. +// Any attempts to actually use this driver are likely to fail at runtime in the most brutal ways. +// + +type Driver = sqlite.Driver + +func IsBusyOrLocked(err error) bool { + return false // FIXME +} +func IsUniqueConstraintViolation(err error) bool { + return false // FIXME +} +func ErrorMessage(err error) string { + return "" // FIXME +} diff --git a/pkg/util/xorm/dialect_sqlite3.go b/pkg/util/xorm/dialect_sqlite3.go index 381a38bb490..4c235ee1c90 100644 --- a/pkg/util/xorm/dialect_sqlite3.go +++ b/pkg/util/xorm/dialect_sqlite3.go @@ -11,8 +11,8 @@ import ( "regexp" "strings" + "github.com/grafana/grafana/pkg/util/sqlite" "github.com/grafana/grafana/pkg/util/xorm/core" - sqlite "github.com/mattn/go-sqlite3" ) var ( @@ -476,11 +476,7 @@ func (db *sqlite3) Filters() []core.Filter { } func (db *sqlite3) RetryOnError(err error) bool { - var sqlError sqlite.Error - if errors.As(err, &sqlError) && (sqlError.Code == sqlite.ErrLocked || sqlError.Code == sqlite.ErrBusy) { - return true - } - return false + return sqlite.IsBusyOrLocked(err) } type sqlite3Driver struct { diff --git a/pkg/util/xorm/xorm_test.go b/pkg/util/xorm/xorm_test.go index f2939fbf9a8..281282a1ef8 100644 --- a/pkg/util/xorm/xorm_test.go +++ b/pkg/util/xorm/xorm_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "testing" - _ "github.com/mattn/go-sqlite3" + _ "github.com/grafana/grafana/pkg/util/sqlite" "github.com/stretchr/testify/require" ) diff --git a/playwright.config.ts b/playwright.config.ts index a3388e20c5e..e1a6b654e73 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -195,6 +195,15 @@ export default defineConfig({ }, dependencies: ['authenticate'], }, + { + name: 'canvas', + testDir: path.join(testDirRoot, '/canvas'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['authenticate'], + }, { name: 'zipkin', testDir: path.join(pluginDirRoot, '/zipkin'), diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 6d5885e3bdd..699a3535e54 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -8321,7 +8321,7 @@ "type": "object", "properties": { "Object": { - "description": "Object is a JSON compatible map with string, float, int, bool, []interface{},\nor map[string]interface{} children.", + "description": "Object is a JSON compatible map with string, float, int, bool, []any,\nor map[string]any children.", "type": "object", "additionalProperties": {} } diff --git a/public/api-merged.json b/public/api-merged.json index 2f2eed4fca5..53f61f1eff1 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -2188,6 +2188,64 @@ } } }, + "/anonymous/devices": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "devices" + ], + "summary": "Lists all devices within the last 30 days", + "operationId": "listDevices", + "responses": { + "200": { + "$ref": "#/responses/devicesResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/anonymous/search": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "devices" + ], + "summary": "Lists all devices within the last 30 days", + "operationId": "SearchDevices", + "responses": { + "200": { + "$ref": "#/responses/devicesSearchResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/cloudmigration/migration": { "get": { "tags": [ @@ -2671,6 +2729,586 @@ } } }, + "/convert/api/prom/rules": { + "get": { + "produces": [ + "application/yaml" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.", + "operationId": "RouteConvertPrometheusCortexGetRules", + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + }, + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "operationId": "RouteConvertPrometheusCortexPostRuleGroups", + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, + "/convert/api/prom/rules/{NamespaceTitle}": { + "get": { + "produces": [ + "application/yaml" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).", + "operationId": "RouteConvertPrometheusCortexGetNamespace", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + }, + "post": { + "description": "If the group already exists and was not imported from a Prometheus-compatible source initially,\nit will not be replaced and an error will be returned.", + "consumes": [ + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.", + "operationId": "RouteConvertPrometheusCortexPostRuleGroup", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "x-grafana-alerting-datasource-uid", + "in": "header" + }, + { + "type": "boolean", + "name": "x-grafana-alerting-recording-rules-paused", + "in": "header" + }, + { + "type": "boolean", + "name": "x-grafana-alerting-alert-rules-paused", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-target-datasource-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-folder-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-notification-receiver", + "in": "header" + }, + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + } + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "x-raw-request": "true" + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.", + "operationId": "RouteConvertPrometheusCortexDeleteNamespace", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, + "/convert/api/prom/rules/{NamespaceTitle}/{Group}": { + "get": { + "produces": [ + "application/yaml" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.", + "operationId": "RouteConvertPrometheusCortexGetRuleGroup", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "PrometheusRuleGroup", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.", + "operationId": "RouteConvertPrometheusCortexDeleteRuleGroup", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, + "/convert/prometheus/config/v1/rules": { + "get": { + "produces": [ + "application/yaml" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.", + "operationId": "RouteConvertPrometheusGetRules", + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + }, + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "operationId": "RouteConvertPrometheusPostRuleGroups", + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, + "/convert/prometheus/config/v1/rules/{NamespaceTitle}": { + "get": { + "produces": [ + "application/yaml" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).", + "operationId": "RouteConvertPrometheusGetNamespace", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "PrometheusNamespace", + "schema": { + "$ref": "#/definitions/PrometheusNamespace" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + }, + "post": { + "description": "If the group already exists and was not imported from a Prometheus-compatible source initially,\nit will not be replaced and an error will be returned.", + "consumes": [ + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.", + "operationId": "RouteConvertPrometheusPostRuleGroup", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "x-grafana-alerting-datasource-uid", + "in": "header" + }, + { + "type": "boolean", + "name": "x-grafana-alerting-recording-rules-paused", + "in": "header" + }, + { + "type": "boolean", + "name": "x-grafana-alerting-alert-rules-paused", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-target-datasource-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-folder-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-notification-receiver", + "in": "header" + }, + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + } + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "x-raw-request": "true" + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.", + "operationId": "RouteConvertPrometheusDeleteNamespace", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, + "/convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group}": { + "get": { + "produces": [ + "application/yaml" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.", + "operationId": "RouteConvertPrometheusGetRuleGroup", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "PrometheusRuleGroup", + "schema": { + "$ref": "#/definitions/PrometheusRuleGroup" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.", + "operationId": "RouteConvertPrometheusDeleteRuleGroup", + "parameters": [ + { + "type": "string", + "name": "NamespaceTitle", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, "/dashboard/snapshots": { "get": { "tags": [ @@ -8672,33 +9310,6 @@ "$ref": "#/responses/internalServerError" } } - }, - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "devices" - ], - "summary": "Lists all devices within the last 30 days", - "operationId": "SearchDevices", - "responses": { - "200": { - "$ref": "#/responses/devicesSearchResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } } }, "/search/sorting": { @@ -9223,35 +9834,6 @@ } } }, - "/stats": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "devices" - ], - "summary": "Lists all devices within the last 30 days", - "operationId": "listDevices", - "responses": { - "200": { - "$ref": "#/responses/devicesResponse" - }, - "401": { - "$ref": "#/responses/unauthorisedError" - }, - "403": { - "$ref": "#/responses/forbiddenError" - }, - "404": { - "$ref": "#/responses/notFoundError" - }, - "500": { - "$ref": "#/responses/internalServerError" - } - } - } - }, "/teams": { "post": { "tags": [ diff --git a/public/app/api/clients/dashboard/v0alpha1/baseAPI.ts b/public/app/api/clients/dashboard/v0alpha1/baseAPI.ts new file mode 100644 index 00000000000..dca53563fa0 --- /dev/null +++ b/public/app/api/clients/dashboard/v0alpha1/baseAPI.ts @@ -0,0 +1,14 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { createBaseQuery } from 'app/api/createBaseQuery'; +import { getAPIBaseURL } from 'app/api/utils'; + +export const BASE_URL = getAPIBaseURL('dashboard.grafana.app', 'v0alpha1'); + +export const api = createApi({ + reducerPath: 'dashboardAPIv0alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts b/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts new file mode 100644 index 00000000000..8730157dedf --- /dev/null +++ b/public/app/api/clients/dashboard/v0alpha1/endpoints.gen.ts @@ -0,0 +1,53 @@ +import { api } from './baseAPI'; +export const addTagTypes = ['Search'] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getSearch: build.query({ + query: (queryArg) => ({ + url: `/search`, + params: { + query: queryArg.query, + folder: queryArg.folder, + sort: queryArg.sort, + }, + }), + providesTags: ['Search'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetSearchApiResponse = /** status 200 undefined */ { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Facet results */ + facets?: { + [key: string]: any; + }; + /** The dashboard body (unstructured for now) */ + hits: any[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** Max score */ + maxScore?: number; + /** Where the query started from */ + offset?: number; + /** Cost of running the query */ + queryCost?: number; + /** How are the results sorted */ + sortBy?: any; + /** The number of matching results */ + totalHits: number; +}; +export type GetSearchApiArg = { + /** user query string */ + query?: string; + /** search/list within a folder (not recursive) */ + folder?: string; + /** sortable field */ + sort?: string; +}; diff --git a/public/app/api/clients/dashboard/v0alpha1/index.ts b/public/app/api/clients/dashboard/v0alpha1/index.ts new file mode 100644 index 00000000000..f61b46bc1f9 --- /dev/null +++ b/public/app/api/clients/dashboard/v0alpha1/index.ts @@ -0,0 +1,27 @@ +import { generatedAPI, GetSearchApiArg } from './endpoints.gen'; + +type OverrideGetSearchRequestOptions = GetSearchApiArg & { + type: string; +}; + +export const dashboardAPIv0alpha1 = generatedAPI.enhanceEndpoints({ + addTagTypes: ['Folder', 'Dashboard'], + endpoints: { + getSearch: (endpointDefinition) => { + const originalQuery = endpointDefinition.query; + endpointDefinition.providesTags = ['Search', 'Folder', 'Dashboard']; + if (originalQuery) { + // TODO: Remove once API spec is updated with `type` + endpointDefinition.query = (requestOptions: OverrideGetSearchRequestOptions) => ({ + ...originalQuery(requestOptions), + params: { + ...requestOptions, + type: requestOptions.type, + }, + }); + } + }, + }, +}); + +export const { useGetSearchQuery } = dashboardAPIv0alpha1; diff --git a/public/app/app.ts b/public/app/app.ts index 0e3ce53e238..5c48558c30c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -50,7 +50,7 @@ import { setPanelRenderer, setPluginPage, } from '@grafana/runtime/internal'; -import { loadResources as loadScenesResources } from '@grafana/scenes'; +import { loadResources as loadScenesResources, sceneUtils } from '@grafana/scenes'; import config, { updateConfig } from 'app/core/config'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -82,6 +82,7 @@ import { initAlerting } from './features/alerting/unified/initAlerting'; import { initAuthConfig } from './features/auth-config'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy'; +import { DashboardLevelTimeMacro } from './features/dashboard-scene/scene/DashboardLevelTimeMacro'; import { initGrafanaLive } from './features/live'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; @@ -284,6 +285,11 @@ export class GrafanaApp { initializeCrashDetection(); } + if (config.featureToggles.dashboardLevelTimeMacros) { + sceneUtils.registerVariableMacro('__from', DashboardLevelTimeMacro, true); + sceneUtils.registerVariableMacro('__to', DashboardLevelTimeMacro, true); + } + const root = createRoot(document.getElementById('reactRoot')!); root.render( createElement(AppWrapper, { diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx index 226583c54e1..38674034a0d 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx @@ -1,89 +1,31 @@ -import { fireEvent, render as rtlRender, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { HttpResponse, http } from 'msw'; -import { SetupServer, setupServer } from 'msw/node'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { fireEvent, render, screen } from 'test/test-utils'; -import { config } from '@grafana/runtime'; +import { config, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { backendSrv } from 'app/core/services/backend_srv'; -import { - treeViewersCanEdit, - wellFormedTree, -} from '../../../features/browse-dashboards/fixtures/dashboardsTreeItem.fixture'; - import { NestedFolderPicker } from './NestedFolderPicker'; -const [mockTree, { folderA, folderB, folderC, folderA_folderA, folderA_folderB }] = wellFormedTree(); -const [mockTreeThatViewersCanEdit /* shares folders with wellFormedTree */] = treeViewersCanEdit(); +const [_, { folderA, folderB, folderC, folderA_folderA, folderA_folderB, folderA_folderC }] = getFolderFixtures(); -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getBackendSrv: () => backendSrv, -})); - -function render(...[ui, options]: Parameters) { - rtlRender({ui}, options); -} +setupMockServer(); +setBackendSrv(backendSrv); describe('NestedFolderPicker', () => { const mockOnChange = jest.fn(); const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; - let server: SetupServer; beforeAll(() => { window.HTMLElement.prototype.scrollIntoView = function () {}; - - server = setupServer( - http.get('/api/folders/:uid', () => { - return HttpResponse.json({ - title: folderA.item.title, - uid: folderA.item.uid, - }); - }), - - http.get('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/settings', () => { - return HttpResponse.json({ - items: [], - }); - }), - - http.get('/api/folders', ({ request }) => { - const url = new URL(request.url); - const parentUid = url.searchParams.get('parentUid') ?? undefined; - const permission = url.searchParams.get('permission'); - - const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10); - const page = parseInt(url.searchParams.get('page') ?? '1', 10); - - const tree = permission === 'Edit' ? mockTreeThatViewersCanEdit : mockTree; - - // reconstruct a folder API response from the flat tree fixture - const folders = tree - .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUid) - .map((folder) => { - return { - uid: folder.item.uid, - title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", - }; - }) - .slice(limit * (page - 1), limit * page); - - return HttpResponse.json(folders); - }) - ); - - server.listen(); }); afterAll(() => { - server.close(); window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); afterEach(() => { jest.resetAllMocks(); - server.resetHandlers(); }); it('renders a button with the correct label when no folder is selected', async () => { @@ -92,18 +34,18 @@ describe('NestedFolderPicker', () => { }); it('renders a button with the correct label when a folder is selected', async () => { - render(); + render(); expect( await screen.findByRole('button', { name: `Select folder: ${folderA.item.title} currently selected` }) ).toBeInTheDocument(); }); it('clicking the button opens the folder picker', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); // Select folder button is no longer visible @@ -118,73 +60,73 @@ describe('NestedFolderPicker', () => { }); it('can select a folder from the picker', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); - await userEvent.click(screen.getByLabelText(folderA.item.title)); + await user.click(screen.getByLabelText(folderA.item.title)); expect(mockOnChange).toHaveBeenCalledWith(folderA.item.uid, folderA.item.title); }); it('can clear a selection if clearable is specified', async () => { - render(); + const { user } = render(); - await userEvent.click(await screen.findByRole('button', { name: 'Clear selection' })); + await user.click(await screen.findByRole('button', { name: 'Clear selection' })); expect(mockOnChange).toHaveBeenCalledWith(undefined, undefined); }); it('can select a folder from the picker with the keyboard', async () => { - render(); + const { user } = render(); const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); - await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}'); - expect(mockOnChange).toHaveBeenCalledWith(folderA.item.uid, folderA.item.title); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + expect(mockOnChange).toHaveBeenCalledWith(folderC.item.uid, folderC.item.title); }); it('shows the root folder by default', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); - await userEvent.click(screen.getByLabelText('Dashboards')); + await user.click(screen.getByLabelText('Dashboards')); expect(mockOnChange).toHaveBeenCalledWith('', 'Dashboards'); }); it('hides the root folder if the prop says so', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); expect(screen.queryByLabelText('Dashboards')).not.toBeInTheDocument(); }); it('hides folders specififed by UID', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); expect(screen.queryByLabelText(folderC.item.title)).not.toBeInTheDocument(); }); it('by default only shows items the user can edit', async () => { - render(); + const { user } = render(); const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); expect(screen.queryByLabelText(folderB.item.title)).not.toBeInTheDocument(); // folderB is not editable @@ -192,10 +134,10 @@ describe('NestedFolderPicker', () => { }); it('shows items the user can view, with the prop', async () => { - render(); + const { user } = render(); const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); expect(screen.getByLabelText(folderB.item.title)).toBeInTheDocument(); @@ -214,11 +156,11 @@ describe('NestedFolderPicker', () => { }); it('can expand and collapse a folder to show its children', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); // Expand Folder A @@ -240,34 +182,35 @@ describe('NestedFolderPicker', () => { fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); // Select the first child - await userEvent.click(screen.getByLabelText(folderA_folderA.item.title)); + await user.click(screen.getByLabelText(folderA_folderA.item.title)); expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); }); it('can expand and collapse a folder to show its children with the keyboard', async () => { - render(); + const { user } = render(); const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); // Expand Folder A - await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}'); // Folder A's children are visible expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument(); expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument(); + expect(await screen.findByLabelText(folderA_folderC.item.title)).toBeInTheDocument(); // Collapse Folder A - await userEvent.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowLeft}'); expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument(); // Expand Folder A again - await userEvent.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); // Select the first child - await userEvent.keyboard('{ArrowDown}{Enter}'); - expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title); + await user.keyboard('{ArrowDown}{Enter}'); + expect(mockOnChange).toHaveBeenCalledWith(folderA_folderC.item.uid, folderA_folderC.item.title); }); }); @@ -283,11 +226,11 @@ describe('NestedFolderPicker', () => { }); it('does not show an expand button', async () => { - render(); + const { user } = render(); // Open the picker and wait for children to load const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); await screen.findByLabelText(folderA.item.title); // There should be no expand button @@ -296,13 +239,13 @@ describe('NestedFolderPicker', () => { }); it('does not expand a folder with the keyboard', async () => { - render(); + const { user } = render(); const button = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(button); + await user.click(button); // try to expand Folder A - await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}'); // Folder A's children are not visible expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument(); diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx b/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx index 18d83068b5e..cf0f0f67af1 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx +++ b/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx @@ -1,82 +1,26 @@ -import { act, renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { act, getWrapper, renderHook, waitFor } from 'test/test-utils'; import { GrafanaConfig } from '@grafana/data'; import * as runtime from '@grafana/runtime'; -import { DashboardsTreeItem } from 'app/features/browse-dashboards/types'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; import { DashboardViewItem } from '../../../features/search/types'; import { useFoldersQuery } from './useFoldersQuery'; import { getRootFolderItem } from './utils'; -const PAGE_SIZE = 10; +const [_, { folderA, folderB, folderC }] = getFolderFixtures(); -const legacyResponse = { - status: 'fulfilled', - originalArgs: { parentUid: undefined, page: 1, limit: PAGE_SIZE, permission: 'Edit' }, - data: [{ title: 'Legacy Folder', uid: 'legacy1', managedBy: undefined }], +runtime.setBackendSrv(backendSrv); +setupMockServer(); + +const wrapper = ({ children }: { children: ReactNode }) => { + const ProviderWrapper = getWrapper({ renderWithRouter: true }); + return {children}; }; -// Mock the legacy API client -jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => { - const PAGE_SIZE = 10; - return { - PAGE_SIZE, - browseDashboardsAPI: { - endpoints: { - listFolders: { - select: jest.fn(() => () => legacyResponse), - initiate: jest.fn(() => ({ - arg: { parentUid: undefined, page: 1, limit: PAGE_SIZE, permission: 'Edit' }, - unsubscribe: jest.fn(), - })), - }, - }, - }, - }; -}); - -const appPlatfromResponse = { - status: 'fulfilled', - originalArgs: { name: 'general' }, - data: { - items: [ - { - metadata: { name: 'app1', annotations: {} }, - spec: { title: 'AppPlatform Folder' }, - }, - ], - }, -}; - -// Mock the appPlatform API client -jest.mock('app/api/clients/folder/v1beta1', () => ({ - folderAPIv1beta1: { - endpoints: { - getFolderChildren: { - select: jest.fn(() => () => appPlatfromResponse), - initiate: jest.fn((arg: unknown) => ({ - arg, - unsubscribe: jest.fn(), - })), - }, - }, - }, -})); - -// Mock getPaginationPlaceholders to return empty array for simplicity -jest.mock('app/features/browse-dashboards/state/utils', () => ({ - getPaginationPlaceholders: jest.fn((): DashboardsTreeItem[] => []), -})); - -// Mock useDispatch and useSelector to just pass through -jest.mock('app/types/store', () => { - const mod = jest.requireActual('app/types/store'); - return { - ...mod, - useDispatch: () => (val: unknown) => val, - useSelector: (selector: Function) => selector(), - }; -}); describe('useFoldersQuery', () => { let configBackup: GrafanaConfig; @@ -89,28 +33,40 @@ describe('useFoldersQuery', () => { runtime.config.featureToggles = configBackup.featureToggles; }); - it('returns data using legacy api', () => { - runtime.config.featureToggles.foldersAppPlatformAPI = false; - const items = testFn(); - expect((items[1].item as DashboardViewItem).title).toBe('Legacy Folder'); - }); + describe.each([ + // foldersAppPlatformAPI enabled + true, + // foldersAppPlatformAPI disabled + false, + ])('foldersAppPlatformAPI feature toggle set to %s', (featureToggleState) => { + it('returns data using legacy api', async () => { + runtime.config.featureToggles.foldersAppPlatformAPI = featureToggleState; + const [_dashboardsContainer, ...items] = await testFn(); - it('returns appPlatform hook result when foldersAppPlatformAPI is on', () => { - runtime.config.featureToggles.foldersAppPlatformAPI = true; - const items = testFn(); - expect((items[1].item as DashboardViewItem).title).toBe('AppPlatform Folder'); + const sortedItemTitles = items.map((item) => (item.item as DashboardViewItem).title).sort(); + const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title].sort(); + + expect(sortedItemTitles).toEqual(expectedTitles); + }); }); }); -function testFn() { - const { result } = renderHook(() => useFoldersQuery(true, {})); +async function testFn() { + const { result } = renderHook(() => useFoldersQuery(true, {}), { wrapper }); - expect(result.current.items).toEqual([getRootFolderItem()]); + expect(result.current.items[0]).toEqual(getRootFolderItem()); expect(result.current.isLoading).toBe(false); + act(() => { result.current.requestNextPage(undefined); }); - expect(result.current.items.length).toBe(2); + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + const withoutPaginationPlaceholders = result.current.items.filter((item) => item.item.kind !== 'ui'); + return expect(withoutPaginationPlaceholders.length).toBeGreaterThan(1); + }); + return result.current.items; } diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts b/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts index 4828417c10c..b877c69aba0 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts +++ b/public/app/core/components/NestedFolderPicker/useFoldersQueryAppPlatform.ts @@ -2,7 +2,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { QueryStatus } from '@reduxjs/toolkit/query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { folderAPIv1beta1 } from 'app/api/clients/folder/v1beta1'; +import { dashboardAPIv0alpha1 } from 'app/api/clients/dashboard/v0alpha1'; import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types'; import { useDispatch, useSelector } from 'app/types/store'; @@ -12,7 +12,7 @@ import { getPaginationPlaceholders } from '../../../features/browse-dashboards/s import { getRootFolderItem } from './utils'; -type GetFolderChildrenQuery = ReturnType>; +type GetFolderChildrenQuery = ReturnType>; type GetFolderChildrenRequest = { unsubscribe: () => void; }; @@ -32,9 +32,9 @@ export function useFoldersQueryAppPlatform(isBrowsing: boolean, openFolders: Rec const requestsRef = useRef([]); // Keep a list of selectors for dynamic state selection - const [selectors, setSelectors] = useState< - Array> - >([]); + const [selectors, setSelectors] = useState>>( + [] + ); // This is an aggregated dynamic selector of all the selectors for all the request issued while loading the folder // tree and returns the whole tree that was loaded so far. @@ -50,7 +50,7 @@ export function useFoldersQueryAppPlatform(isBrowsing: boolean, openFolders: Rec isLoading = true; } - const parentName = response.originalArgs?.name; + const parentName = response.originalArgs?.folder; if (parentName) { responseByParent[parentName] = response; } @@ -77,13 +77,13 @@ export function useFoldersQueryAppPlatform(isBrowsing: boolean, openFolders: Rec return; } - const args = { name: finalParentUid }; + const args = { folder: finalParentUid, type: 'folder' }; // Make a request - const subscription = dispatch(folderAPIv1beta1.endpoints.getFolderChildren.initiate(args)); + const subscription = dispatch(dashboardAPIv0alpha1.endpoints.getSearch.initiate(args)); // Add selector for the response to the list so we can then have an aggregated selector for all the folders - const selector = folderAPIv1beta1.endpoints.getFolderChildren.select(args); + const selector = dashboardAPIv0alpha1.endpoints.getSearch.select(args); setSelectors((selectors) => selectors.concat(selector)); // the subscriptions are saved in a ref so they can be unsubscribed on unmount @@ -113,18 +113,18 @@ export function useFoldersQueryAppPlatform(isBrowsing: boolean, openFolders: Rec response: GetFolderChildrenQuery | undefined, level: number ): Array> { - let folders = response?.data?.items ? [...response.data.items] : []; - folders.sort((a, b) => collator.compare(a.spec.title, b.spec.title)); + let folders = response?.data?.hits ? [...response.data.hits] : []; + folders.sort((a, b) => collator.compare(a.title, b.title)); const list = folders.flatMap((item) => { - const name = item.metadata.name!; + const name = item.name; const folderIsOpen = openFolders[name]; const flatItem: DashboardsTreeItem = { isOpen: Boolean(folderIsOpen), level: level, item: { kind: 'folder' as const, - title: item.spec.title, + title: item.title, // We use resource name as UID because well, not sure what metadata.uid would be used for now as you cannot // query by it. uid: name, diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index ce86bef4eae..810e2676755 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -74,6 +74,8 @@ "unicons/file-alt", "unicons/file-blank", "unicons/filter", + "unicons/filter-plus", + "unicons/filter-minus", "unicons/folder", "unicons/folder-open", "unicons/folder-plus", diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 67078fdbd27..bc296ee14b6 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -2,6 +2,7 @@ import { ReducersMapObject } from '@reduxjs/toolkit'; import { AnyAction, combineReducers } from 'redux'; import { alertingAPI as alertingPackageAPI } from '@grafana/alerting/unstable'; +import { dashboardAPIv0alpha1 } from 'app/api/clients/dashboard/v0alpha1'; import sharedReducers from 'app/core/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; @@ -71,6 +72,7 @@ const rootReducers = { [provisioningAPIv0alpha1.reducerPath]: provisioningAPIv0alpha1.reducer, [folderAPIv1beta1.reducerPath]: folderAPIv1beta1.reducer, [advisorAPIv0alpha1.reducerPath]: advisorAPIv0alpha1.reducer, + [dashboardAPIv0alpha1.reducerPath]: dashboardAPIv0alpha1.reducer, // PLOP_INJECT_REDUCER // Used by the API client generator }; diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx index 40a7afeddeb..1825b5e40a0 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx @@ -68,14 +68,14 @@ export const ContactPointHeader = ({ contactPoint, onDelete }: ContactPointHeade */ const isReferencedByAnything = usingK8sApi ? Boolean(numberOfPolicies || numberOfRules) : policies.length > 0; /** Does the current user have permissions to edit the contact point? */ - const hasAbilityToEdit = canEditEntity(contactPoint) || editAllowed; + const hasAbilityToEdit = usingK8sApi ? canEditEntity(contactPoint) : editAllowed; /** Can the contact point actually be edited via the UI? */ const contactPointIsEditable = !provisioned; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be edited? */ const canEdit = editSupported && hasAbilityToEdit && contactPointIsEditable; /** Does the current user have permissions to delete the contact point? */ - const hasAbilityToDelete = canDeleteEntity(contactPoint) || deleteAllowed; + const hasAbilityToDelete = usingK8sApi ? canDeleteEntity(contactPoint) : deleteAllowed; /** Can the contact point actually be deleted, regardless of permissions? i.e. ensuring it isn't provisioned and isn't referenced elsewhere */ const contactPointIsDeleteable = !provisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules; /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be deleted? */ diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index 79cec523d69..db39d474957 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -198,12 +198,37 @@ describe('contact points', () => { const unusedBadge = screen.getAllByLabelText('unused'); expect(unusedBadge).toHaveLength(4); - const viewProvisioned = screen.getByTestId('view-action'); - expect(viewProvisioned).toBeInTheDocument(); - expect(viewProvisioned).toBeEnabled(); + // Two contact points should have view buttons: grafana-default-email (cannot be edited) and provisioned-contact-point (provisioned) + const viewButtons = screen.getAllByRole('link', { name: /^view$/i }); + expect(viewButtons).toHaveLength(2); + + // Check view buttons by their href to verify which contact points they belong to + // The url is the same but the form should be readonly + expect(viewButtons[0]).toHaveAttribute('href', '/alerting/notifications/receivers/grafana-default-email/edit'); + expect(viewButtons[1]).toHaveAttribute( + 'href', + '/alerting/notifications/receivers/provisioned-contact-point/edit' + ); + + viewButtons.forEach((button) => { + expect(button).toBeEnabled(); + }); + + // Three contact points should have edit buttons: lotsa-emails, Slack with multiple channels, OnCall Contact point + const editButtons = screen.getAllByRole('link', { name: /^edit$/i }); + expect(editButtons).toHaveLength(3); + + // Check edit buttons by their href to verify which contact points they belong to + expect(editButtons[0]).toHaveAttribute('href', '/alerting/notifications/receivers/lotsa-emails/edit'); + expect(editButtons[1]).toHaveAttribute( + 'href', + '/alerting/notifications/receivers/OnCall%20Conctact%20point/edit' + ); + expect(editButtons[2]).toHaveAttribute( + 'href', + '/alerting/notifications/receivers/Slack%20with%20multiple%20channels/edit' + ); - const editButtons = screen.getAllByTestId('edit-action'); - expect(editButtons).toHaveLength(4); editButtons.forEach((button) => { expect(button).toBeEnabled(); }); @@ -227,11 +252,11 @@ describe('contact points', () => { expect(screen.getByRole('link', { name: 'add contact point' })).toHaveAttribute('aria-disabled', 'true'); // edit permission is based on API response - we should have 3 buttons - const editButtons = await screen.findAllByTestId('edit-action'); + const editButtons = await screen.findAllByRole('link', { name: /^edit$/i }); expect(editButtons).toHaveLength(3); // there should be view buttons though - one for provisioned, and one for the un-editable contact point - const viewButtons = screen.getAllByTestId('view-action'); + const viewButtons = screen.getAllByRole('link', { name: /^view$/i }); expect(viewButtons).toHaveLength(2); // check buttons in Notification Templates @@ -329,7 +354,18 @@ describe('contact points', () => { }, ]; - const { user } = renderWithProvider(); + // Add the necessary K8s annotations to allow deletion + const contactPointWithDeletePermission: ContactPointWithMetadata = { + ...basicContactPoint, + metadata: { + annotations: { + [K8sAnnotations.AccessDelete]: 'true', + }, + }, + policies, + }; + + const { user } = renderWithProvider(); const moreActions = screen.getByRole('button', { name: /More/ }); await user.click(moreActions); @@ -387,7 +423,7 @@ describe('contact points', () => { const unusedBadge = screen.getAllByLabelText('unused'); expect(unusedBadge).toHaveLength(1); - const editButtons = screen.getAllByTestId('edit-action'); + const editButtons = screen.getAllByRole('link', { name: /^edit$/i }); expect(editButtons).toHaveLength(2); editButtons.forEach((button) => { expect(button).toBeEnabled(); @@ -431,9 +467,9 @@ describe('contact points', () => { expect(screen.queryByRole('link', { name: 'add contact point' })).not.toBeInTheDocument(); - const viewProvisioned = screen.getByTestId('view-action'); - expect(viewProvisioned).toBeInTheDocument(); - expect(viewProvisioned).toBeEnabled(); + const viewButton = screen.getByRole('link', { name: /^view$/i }); + expect(viewButton).toBeInTheDocument(); + expect(viewButton).toBeEnabled(); // check buttons in Notification Templates const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' }); diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx index 04f6fcdb435..75413e20e33 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx @@ -44,7 +44,8 @@ beforeEach(() => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]); }); -const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).findByTestId('mockeditor'); +const getTemplatePreviewContent = async () => + within(await screen.findByTestId('template-preview')).findByTestId('mockeditor'); const templatesSelectorTestId = 'existing-templates-selector'; diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index 44da5c3588b..8971c9b34d7 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -135,9 +135,9 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } } }; - const isEditable = Boolean( - (!readOnly || (contactPoint && canEditEntity(contactPoint))) && !contactPoint?.provisioned - ); + // If there is no contact point it means we're creating a new one, so scoped permissions doesn't exist yet + const hasScopedEditPermissions = contactPoint ? canEditEntity(contactPoint) : true; + const isEditable = !readOnly && hasScopedEditPermissions && !contactPoint?.provisioned; const isTestable = !readOnly; if (isLoadingNotifiers || isLoadingOnCallIntegration) { diff --git a/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx index 3fc7eb8a808..3850092b40d 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceRuleListItem.tsx @@ -5,6 +5,7 @@ import { PromRuleType, RulerRuleDTO, RulesSourceApplication } from 'app/types/un import { createReturnTo } from '../hooks/useReturnTo'; import { Annotation } from '../utils/constants'; +import { groups } from '../utils/navigation'; import { fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id'; import { getRuleName, getRulePluginOrigin, rulerRuleType } from '../utils/rules'; import { createRelativeUrl } from '../utils/url'; @@ -46,11 +47,14 @@ export function DataSourceRuleListItem({ const ruleName = rulerRule ? getRuleName(rulerRule) : rule.name; const labels = rulerRule ? rulerRule.labels : rule.labels; + const groupUrl = groups.detailsPageLink(rulesSource.uid, namespace.name, groupName); + const commonProps: RuleListItemCommonProps = { name: ruleName, rulesSource: rulesSource, application: application, group: groupName, + groupUrl, namespace: namespace.name, href, health: rule.health, diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index 908e5978d88..3a7348048da 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -107,6 +107,30 @@ describe('RuleList - FilterView', () => { expect(await screen.findByText(/No matching rules found/)).toBeInTheDocument(); }); + + it('should render group names as clickable links', async () => { + render( + + ); + + await loadMoreResults(); + + const groupLink = await screen.findByRole('link', { + name: 'test-group-4501', + }); + + expect(groupLink).toBeInTheDocument(); + expect(groupLink).toHaveAttribute( + 'href', + '/alerting/mimir/namespaces/test-mimir-namespace/groups/test-group-4501/view' + ); + }); }); async function loadMoreResults() { diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx index 3113c991e09..ed90116bcaa 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx @@ -1,7 +1,8 @@ import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; import { GrafanaPromRuleDTO, PromRuleType } from 'app/types/unified-alerting-dto'; -import { GrafanaRulesSource } from '../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME, GrafanaRulesSource } from '../utils/datasource'; +import { groups } from '../utils/navigation'; import { totalFromStats } from '../utils/ruleStats'; import { prometheusRuleType } from '../utils/rules'; import { createRelativeUrl } from '../utils/url'; @@ -32,10 +33,17 @@ export function GrafanaRuleListItem({ }: GrafanaRuleListItemProps) { const { name, uid, labels, provenance } = rule; + const groupUrl = groups.detailsPageLink( + GRAFANA_RULES_SOURCE_NAME, + groupIdentifier.namespace.uid, + groupIdentifier.groupName + ); + const commonProps: RuleListItemCommonProps = { name, rulesSource: GrafanaRulesSource, group: groupIdentifier.groupName, + groupUrl, namespace: namespaceName, href: createRelativeUrl(`/alerting/grafana/${uid}/view`), health: rule?.health, @@ -45,6 +53,7 @@ export function GrafanaRuleListItem({ isPaused: rule?.isPaused, application: 'grafana' as const, actions: , + querySourceUIDs: rule?.queriedDatasourceUIDs, }; if (prometheusRuleType.grafana.alertingRule(rule)) { diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx index 3f8c01831e6..2694bfc580d 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -1,10 +1,10 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import pluralize from 'pluralize'; -import { ReactNode, useEffect, useId } from 'react'; +import { ReactNode, forwardRef, memo, useEffect, useId } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; +import { Alert, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; import { Rule, RuleGroupIdentifierV2, RuleHealth, RulesSourceIdentifier } from 'app/types/unified-alerting'; import { Labels, PromAlertingRuleState, RulerRuleDTO, RulesSourceApplication } from 'app/types/unified-alerting-dto'; @@ -13,15 +13,15 @@ import { AlertLabels } from '../../components/AlertLabels'; import { MetaText } from '../../components/MetaText'; import { ProvisioningBadge } from '../../components/Provisioning'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME, getDataSourceByUid } from '../../utils/datasource'; import { getGroupOriginName } from '../../utils/groupIdentifier'; import { labelsSize } from '../../utils/labels'; import { createContactPointSearchLink } from '../../utils/misc'; import { RulePluginOrigin } from '../../utils/rules'; import { ListItem } from './ListItem'; -import { DataSourceIcon } from './Namespace'; import { RuleListIcon, RuleOperation } from './RuleListIcon'; +import { RuleLocation } from './RuleLocation'; import { calculateNextEvaluationEstimate } from './util'; export interface AlertRuleListItemProps { @@ -39,6 +39,7 @@ export interface AlertRuleListItemProps { instancesCount?: number; namespace?: string; group?: string; + groupUrl?: string; rulesSource?: RulesSourceIdentifier; application?: RulesSourceApplication; // used for alert rules that use simplified routing @@ -48,6 +49,7 @@ export interface AlertRuleListItemProps { operation?: RuleOperation; // the grouped view doesn't need to show the location again – it's redundant showLocation?: boolean; + querySourceUIDs?: string[]; } export const AlertRuleListItem = (props: AlertRuleListItemProps) => { @@ -65,6 +67,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { instancesCount = 0, namespace, group, + groupUrl, rulesSource, application, contactPoint, @@ -73,6 +76,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { actions = null, operation, showLocation = true, + querySourceUIDs = [], } = props; const listItemAriaId = useId(); @@ -81,11 +85,21 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { if (namespace && group && showLocation) { metadata.push( - + ); } + if (querySourceUIDs.length > 0) { + metadata.push(); + } + if (!isPaused) { if (lastEvaluation && evaluationInterval) { metadata.push( @@ -160,6 +174,7 @@ export function RecordingRuleListItem({ name, namespace, group, + groupUrl, rulesSource, application, href, @@ -170,16 +185,27 @@ export function RecordingRuleListItem({ origin, actions, showLocation = true, + querySourceUIDs = [], }: RecordingRuleListItemProps) { const metadata: ReactNode[] = []; if (namespace && group && showLocation) { metadata.push( - + ); } + if (querySourceUIDs.length > 0) { + metadata.push(); + } + return ( - + ); } @@ -270,6 +304,29 @@ function Summary({ content, error }: SummaryProps) { return null; } +interface QuerySourceIconsProps { + queriedDatasourceUIDs: string[]; +} + +const QuerySourceIcons = memo(function QuerySourceIcons({ queriedDatasourceUIDs }: QuerySourceIconsProps) { + // Make icons unique - deduplicate datasource UIDs + const dataSources = Array.from(new Set(queriedDatasourceUIDs)) + .map(getDataSourceByUid) + .filter((ds): ds is DataSourceInstanceSettings => ds !== undefined); + + return ( + + {dataSources.map((dataSource) => { + return ( + + + + ); + })} + + ); +}); + function RuleLabels({ labels }: { labels: Labels }) { const styles = useStyles2(getStyles); @@ -368,38 +425,6 @@ export const UnknownRuleListItem = ({ ruleName, groupIdentifier, ruleDefinition ); }; -interface RuleLocationProps { - namespace: string; - group: string; - rulesSource?: RulesSourceIdentifier; - application?: RulesSourceApplication; -} - -// @TODO make the datasource / namespace / group click-able to allow further filtering of the list -export const RuleLocation = ({ namespace, group, rulesSource, application }: RuleLocationProps) => { - const isGrafanaApp = application === 'grafana'; - const isDataSourceApp = !!rulesSource && !!application && !isGrafanaApp; - - return ( - - {isGrafanaApp && } - {isDataSourceApp && ( - - - - - - )} - - - {namespace} - - {group} - - - ); -}; - const getStyles = (theme: GrafanaTheme2) => ({ alertListItemContainer: css({ position: 'relative', @@ -426,3 +451,33 @@ export type RuleListItemCommonProps = Pick< AlertRuleListItemProps, Extract >; + +interface DataSourceLogoProps { + dataSource: DataSourceInstanceSettings; +} + +const DataSourceLogo = forwardRef(({ dataSource }, ref) => { + const styles = useStyles2(dataSourceLogoStyles); + + return ( + {`${dataSource.meta.name} + ); +}); + +const dataSourceLogoStyles = (theme: GrafanaTheme2) => ({ + logo: css({ + height: '14px', + width: '14px', + borderRadius: theme.shape.radius.default, + }), + filter: css({ + filter: `invert(${theme.isLight ? 1 : 0})`, + }), +}); diff --git a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx index ce3ea7f6fb6..0e15e5fb696 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx @@ -39,7 +39,7 @@ export const ListItem = (props: ListItemProps) => { {/* metadata */} - + {meta?.map((item, index) => ( {index > 0 && } @@ -72,7 +72,7 @@ export const SkeletonListItem = () => { const Separator = () => ( - {'·'} + {'|'} ); diff --git a/public/app/features/alerting/unified/rule-list/components/RuleLocation.tsx b/public/app/features/alerting/unified/rule-list/components/RuleLocation.tsx new file mode 100644 index 00000000000..b0d4b31de5d --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/components/RuleLocation.tsx @@ -0,0 +1,43 @@ +import { Icon, Stack, TextLink, Tooltip } from '@grafana/ui'; +import { RulesSourceIdentifier } from 'app/types/unified-alerting'; +import { RulesSourceApplication } from 'app/types/unified-alerting-dto'; + +import { DataSourceIcon } from './Namespace'; + +interface RuleLocationProps { + namespace: string; + group: string; + groupUrl?: string; + rulesSource?: RulesSourceIdentifier; + application?: RulesSourceApplication; +} + +export function RuleLocation({ namespace, group, groupUrl, rulesSource, application }: RuleLocationProps) { + const isGrafanaApp = application === 'grafana'; + const isDataSourceApp = !!rulesSource && !!application && !isGrafanaApp; + + return ( + + {isGrafanaApp && } + {isDataSourceApp && ( + + + + + + )} + + + {namespace} + + {groupUrl ? ( + + {group} + + ) : ( + group + )} + + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts index 3602081e7c0..2e9535dd97f 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -22,12 +22,10 @@ export function groupFilter( const { name, file } = group; const { namespace, groupName } = filterState; - // Use fuzzy search for namespace if (namespace && !fuzzyMatches(file, namespace)) { return false; } - // Use fuzzy search for group name if (groupName && !fuzzyMatches(name, groupName)) { return false; } @@ -41,17 +39,17 @@ export function groupFilter( export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { const { name, labels = {}, health, type } = rule; - // Free form words filter (uses fuzzy matching for each word) - if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => fuzzyMatches(name, word))) { - return false; + if (filterState.freeFormWords.length > 0) { + const nameMatches = fuzzyMatches(name, filterState.freeFormWords.join(' ')); + if (!nameMatches) { + return false; + } } - // Rule name filter (uses fuzzy matching) if (filterState.ruleName && !fuzzyMatches(name, filterState.ruleName)) { return false; } - // Labels filter if (filterState.labels.length > 0) { const matchers = compact(filterState.labels.map(looseParseMatcher)); const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers); @@ -68,12 +66,10 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { } } - // Rule type filter if (filterState.ruleType && type !== filterState.ruleType) { return false; } - // Rule state filter (for alerting rules only) if (filterState.ruleState) { if (!prometheusRuleType.alertingRule(rule)) { return false; @@ -83,7 +79,6 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { } } - // Rule health filter if (filterState.ruleHealth && health !== filterState.ruleHealth) { return false; } @@ -102,7 +97,6 @@ export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { } } - // Dashboard UID filter if (filterState.dashboardUid) { if (!prometheusRuleType.alertingRule(rule)) { return false; diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index 4a2c9f4a82b..a9a0dda6274 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -1,7 +1,6 @@ import { render as rtlRender, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HttpResponse, http } from 'msw'; -import { setupServer, SetupServer } from 'msw/node'; import { ComponentProps } from 'react'; import * as React from 'react'; import { useParams } from 'react-router-dom-v5-compat'; @@ -9,13 +8,16 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { TestProvider } from 'test/helpers/TestProvider'; import { selectors } from '@grafana/e2e-selectors'; +import server, { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { contextSrv } from 'app/core/core'; import { backendSrv } from 'app/core/services/backend_srv'; import BrowseDashboardsPage from './BrowseDashboardsPage'; -import { wellFormedTree } from './fixtures/dashboardsTreeItem.fixture'; import * as permissions from './permissions'; -const [mockTree, { dashbdD, folderA, folderA_folderA }] = wellFormedTree(); + +setupMockServer(); +const [mockTree, { dashbdD, folderA, folderA_folderA }] = getFolderFixtures(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -111,7 +113,6 @@ jest.mock('app/features/browse-dashboards/api/services', () => { }); describe('browse-dashboards BrowseDashboardsPage', () => { - let server: SetupServer; const mockPermissions = { canCreateDashboards: true, canEditDashboards: true, @@ -123,33 +124,14 @@ describe('browse-dashboards BrowseDashboardsPage', () => { canDeleteDashboards: true, }; - beforeAll(() => { - server = setupServer( - http.get('/api/folders/:uid', () => { - return HttpResponse.json({ - title: folderA.item.title, - uid: folderA.item.uid, - }); - }), - http.get('/api/search', () => { - return HttpResponse.json({}); - }), + beforeEach(() => { + server.use( http.get('/api/search/sorting', () => { return HttpResponse.json({ sortOptions: [], }); - }), - http.get('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/settings', () => { - return HttpResponse.json({ - items: [], - }); }) ); - server.listen(); - }); - - afterAll(() => { - server.close(); }); beforeEach(() => { @@ -170,7 +152,6 @@ describe('browse-dashboards BrowseDashboardsPage', () => { canDeleteDashboards: true, }); jest.restoreAllMocks(); - server.resetHandlers(); }); describe('at the root level', () => { diff --git a/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx b/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx index c60a8a8de56..9b96316add6 100644 --- a/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderLibraryPanelsPage.test.tsx @@ -1,9 +1,9 @@ -import { render as rtlRender, screen } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import { SetupServer, setupServer } from 'msw/node'; import { useParams } from 'react-router-dom-v5-compat'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render, screen } from 'test/test-utils'; +import server, { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { contextSrv } from 'app/core/core'; import { backendSrv } from 'app/core/services/backend_srv'; @@ -11,10 +11,7 @@ import BrowseFolderLibraryPanelsPage from './BrowseFolderLibraryPanelsPage'; import { getLibraryElementsResponse } from './fixtures/libraryElements.fixture'; import * as permissions from './permissions'; -function render(...[ui, options]: Parameters) { - rtlRender({ui}, options); -} - +setupMockServer(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => backendSrv, @@ -28,15 +25,15 @@ jest.mock('react-router-dom-v5-compat', () => ({ useParams: jest.fn(), })); -const mockFolderName = 'myFolder'; -const mockFolderUid = '12345'; +const [_, { folderA }] = getFolderFixtures(); +const mockFolderName = folderA.item.title; +const mockFolderUid = folderA.item.uid; const mockLibraryElementsResponse = getLibraryElementsResponse(1, { folderUid: mockFolderUid, }); describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => { (useParams as jest.Mock).mockReturnValue({ uid: mockFolderUid }); - let server: SetupServer; const mockPermissions = { canCreateDashboards: true, canEditDashboards: true, @@ -48,14 +45,8 @@ describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => { canDeleteDashboards: true, }; - beforeAll(() => { - server = setupServer( - http.get('/api/folders/:uid', () => { - return HttpResponse.json({ - title: mockFolderName, - uid: mockFolderUid, - }); - }), + beforeEach(() => { + server.use( http.get('/api/library-elements', () => { return HttpResponse.json({ result: mockLibraryElementsResponse, @@ -65,11 +56,6 @@ describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => { return HttpResponse.json({}); }) ); - server.listen(); - }); - - afterAll(() => { - server.close(); }); beforeEach(() => { @@ -79,7 +65,6 @@ describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => { afterEach(() => { jest.restoreAllMocks(); - server.resetHandlers(); }); it('displays the folder title', async () => { diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx index fb1a160d8b1..c941cd76150 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx @@ -1,38 +1,26 @@ -import userEvent from '@testing-library/user-event'; import { HttpResponse, http } from 'msw'; -import { SetupServer, setupServer } from 'msw/node'; import { render, screen } from 'test/test-utils'; +import { setBackendSrv } from '@grafana/runtime'; +import server, { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { backendSrv } from 'app/core/services/backend_srv'; -import { treeViewersCanEdit, wellFormedTree } from '../../fixtures/dashboardsTreeItem.fixture'; - import { MoveModal, Props } from './MoveModal'; -const [mockTree, { folderA }] = wellFormedTree(); -const [mockTreeThatViewersCanEdit /* shares folders with wellFormedTree */] = treeViewersCanEdit(); +const [_, { folderA }] = getFolderFixtures(); -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getBackendSrv: () => backendSrv, -})); +setBackendSrv(backendSrv); +setupMockServer(); describe('browse-dashboards MoveModal', () => { const mockOnDismiss = jest.fn(); const mockOnConfirm = jest.fn(); let props: Props; - let server: SetupServer; window.HTMLElement.prototype.scrollIntoView = () => {}; - beforeAll(() => { - server = setupServer( - http.get('/api/folders/:uid', () => { - return HttpResponse.json({ - title: folderA.item.title, - uid: folderA.item.uid, - }); - }), - + beforeEach(() => { + server.use( http.get('/api/folders/:uid/counts', () => { return HttpResponse.json({ folder: 1, @@ -40,43 +28,9 @@ describe('browse-dashboards MoveModal', () => { librarypanel: 3, alertrule: 4, }); - }), - - http.get('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/settings', () => { - return HttpResponse.json({ - items: [], - }); - }), - - http.get('/api/folders', ({ request }) => { - const url = new URL(request.url); - const parentUid = url.searchParams.get('parentUid') ?? undefined; - const permission = url.searchParams.get('permission'); - - const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10); - const page = parseInt(url.searchParams.get('page') ?? '1', 10); - - const tree = permission === 'Edit' ? mockTreeThatViewersCanEdit : mockTree; - - // reconstruct a folder API response from the flat tree fixture - const folders = tree - .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUid) - .map((folder) => { - return { - uid: folder.item.uid, - title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", - }; - }) - .slice(limit * (page - 1), limit * page); - - return HttpResponse.json(folders); }) ); - server.listen(); - }); - - beforeEach(() => { props = { isOpen: true, onConfirm: mockOnConfirm, @@ -90,10 +44,6 @@ describe('browse-dashboards MoveModal', () => { }; }); - afterAll(() => { - server.close(); - }); - it('renders a dialog with the correct title', async () => { render(); @@ -130,36 +80,36 @@ describe('browse-dashboards MoveModal', () => { }); it('enables the `Move` button once a folder is selected', async () => { - render(); + const { user } = render(); expect(await screen.findByRole('button', { name: 'Move' })).toBeDisabled(); // Open the picker and wait for children to load const folderPicker = await screen.findByRole('button', { name: 'Select folder' }); - await userEvent.click(folderPicker); + await user.click(folderPicker); await screen.findByLabelText(folderA.item.title); // Select the folder - await userEvent.click(screen.getByLabelText(folderA.item.title)); + await user.click(screen.getByLabelText(folderA.item.title)); const moveButton = await screen.findByRole('button', { name: 'Move' }); expect(moveButton).toBeEnabled(); - await userEvent.click(moveButton); + await user.click(moveButton); expect(mockOnConfirm).toHaveBeenCalledWith(folderA.item.uid); }); it('calls onDismiss when clicking the `Cancel` button', async () => { - render(); + const { user } = render(); - await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); expect(mockOnDismiss).toHaveBeenCalled(); }); it('calls onDismiss when clicking the X', async () => { - render(); + const { user } = render(); - await userEvent.click(await screen.findByRole('button', { name: 'Close' })); + await user.click(await screen.findByRole('button', { name: 'Close' })); expect(mockOnDismiss).toHaveBeenCalled(); }); }); diff --git a/public/app/features/browse-dashboards/components/BrowseView.test.tsx b/public/app/features/browse-dashboards/components/BrowseView.test.tsx index 1a19d2b5b61..929f5bbf53c 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.test.tsx @@ -3,14 +3,13 @@ import userEvent from '@testing-library/user-event'; import { TestProvider } from 'test/helpers/TestProvider'; import { selectors } from '@grafana/e2e-selectors'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { DashboardViewItem } from 'app/features/search/types'; -import { wellFormedTree } from '../fixtures/dashboardsTreeItem.fixture'; - import { BrowseView } from './BrowseView'; const [mockTree, { folderA, folderA_folderA, folderA_folderB, folderA_folderB_dashbdB, dashbdD, folderB_empty }] = - wellFormedTree(); + getFolderFixtures(); function render(...[ui, options]: Parameters) { rtlRender({ui}, options); diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx index 00687677e94..a3ddd150785 100644 --- a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx +++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx @@ -49,6 +49,26 @@ jest.mock('app/features/dashboard-scene/components/Provisioned/ResourceEditFormS ResourceEditFormSharedFields: () =>
, })); +const MOCK_DATA = { + repository: { + name: 'test-repo', + namespace: 'default', + title: 'Test Repository', + type: 'git', + }, + resource: { + type: { + kind: 'Folder', + }, + upsert: { + apiVersion: 'v1', + kind: 'Folder', + metadata: { name: 'test-folder', uid: 'test-folder-uid' }, + spec: { title: 'Test Folder' }, + }, + }, +}; + const mockUseDeleteRepositoryFilesMutation = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction< typeof useDeleteRepositoryFilesWithPathMutation >; @@ -267,7 +287,13 @@ describe('DeleteProvisionedFolderForm', () => { describe('success handling', () => { it('should navigate to parent folder on successful write workflow', async () => { - const successState = { isLoading: false, isSuccess: true, isError: false, error: null }; + const successState = { + isLoading: false, + isSuccess: true, + isError: false, + error: null, + data: MOCK_DATA, + }; setup({}, defaultHookData, successState); await waitFor(() => { @@ -277,7 +303,13 @@ describe('DeleteProvisionedFolderForm', () => { it('should navigate to dashboards root when parent folder has no parentUid', async () => { const folderWithoutParent = { ...mockParentFolder, parentUid: undefined }; - const successState = { isLoading: false, isSuccess: true, isError: false, error: null }; + const successState = { + isLoading: false, + isSuccess: true, + isError: false, + error: null, + data: MOCK_DATA, + }; setup({ parentFolder: folderWithoutParent }, defaultHookData, successState); await waitFor(() => { @@ -292,13 +324,19 @@ describe('DeleteProvisionedFolderForm', () => { isSuccess: true, isError: false, error: null, - data: { urls: { newPullRequestURL: 'https://github.com/test/repo/pull/new' } }, + data: { + ...MOCK_DATA, + ref: 'feature-branch', + path: 'folders/test-folder.json', + urls: { newPullRequestURL: 'https://github.com/test/repo/pull/new' }, + }, }; const { mockNavigate } = setup({}, { ...defaultHookData, initialValues: branchFormData }, successState); await waitFor(() => { const expectedParams = new URLSearchParams(); expectedParams.set('new_pull_request_url', 'https://github.com/test/repo/pull/new'); + expectedParams.set('repo_type', 'git'); const expectedUrl = `/dashboards?${expectedParams.toString()}`; expect(mockNavigate).toHaveBeenCalledWith(expectedUrl); diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx index 70638377a3a..f6fb26101f8 100644 --- a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; @@ -12,6 +11,10 @@ import { AnnoKeySourcePath } from 'app/features/apiserver/types'; import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared'; import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; +import { + useProvisionedRequestHandler, + ProvisionedOperationInfo, +} from 'app/features/dashboard-scene/utils/useProvisionedRequestHandler'; import { FolderDTO } from 'app/types/folders'; import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData'; @@ -57,50 +60,51 @@ function FormContent({ initialValues, parentFolder, repository, workflowOptions, }); }; - // TODO: move to a hook if this useEffect shared mostly the same logic as in NewProvisionedFolderForm - useEffect(() => { - if (request.isSuccess && repository) { - const prUrl = request.data?.urls?.newPullRequestURL; - if (workflow === 'branch' && prUrl) { - const url = buildResourceBranchRedirectUrl({ - paramName: 'new_pull_request_url', - paramValue: prUrl, - repoType: request.data?.repository?.type, - }); - navigate(url); - return; - } - - if (workflow === 'write') { - getAppEvents().publish({ - type: AppEvents.alertSuccess.name, - payload: [ - t( - 'browse-dashboards.delete-provisioned-folder-form.alert-folder-deleted-successfully', - 'Folder deleted successfully' - ), - ], - }); - // Navigate back to parent folder if it exists, otherwise go to dashboards root - if (parentFolder?.parentUid) { - window.location.href = getFolderURL(parentFolder.parentUid); - } else { - window.location.href = '/dashboards'; - } - } - } - - if (request.isError) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [ - t('browse-dashboards.delete-provisioned-folder-form.api-error', 'Failed to delete folder'), - request.error, - ], + const onBranchSuccess = ({ urls }: { urls?: Record }, info: ProvisionedOperationInfo) => { + const prUrl = urls?.newPullRequestURL; + if (prUrl) { + const url = buildResourceBranchRedirectUrl({ + paramName: 'new_pull_request_url', + paramValue: prUrl, + repoType: info.repoType, }); - return; + navigate(url); } - }, [request, repository, workflow, parentFolder, navigate]); + }; + + const onWriteSuccess = () => { + // Navigate back to parent folder if it exists, otherwise go to dashboards root + if (parentFolder?.parentUid) { + window.location.href = getFolderURL(parentFolder.parentUid); + } else { + window.location.href = '/dashboards'; + } + }; + + const onError = (error: unknown) => { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [t('browse-dashboards.delete-provisioned-folder-form.api-error', 'Failed to delete folder'), error], + }); + }; + + // Use the repository-type and resource-type aware provisioned request handler + useProvisionedRequestHandler({ + request, + workflow, + successMessage: t( + 'browse-dashboards.delete-provisioned-folder-form.success-message', + 'Folder deleted successfully' + ), + resourceType: 'folder', + repository, + handlers: { + onDismiss, + onBranchSuccess, + onWriteSuccess, + onError, + }, + }); return ( diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx index a453490e07c..797f4bb98ba 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -1,5 +1,4 @@ import { css } from '@emotion/css'; -import { useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; @@ -13,6 +12,10 @@ import { AnnoKeySourcePath, Resource } from 'app/features/apiserver/types'; import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared'; import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; +import { + useProvisionedRequestHandler, + ProvisionedOperationInfo, +} from 'app/features/dashboard-scene/utils/useProvisionedRequestHandler'; import { PROVISIONING_URL } from 'app/features/provisioning/constants'; import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; import { FolderDTO } from 'app/types/folders'; @@ -44,58 +47,60 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis }); const { handleSubmit, watch, register, formState } = methods; - const [workflow, ref, title] = watch(['workflow', 'ref', 'title']); + const [workflow, title] = watch(['workflow', 'title']); - // TODO: replace with useProvisionedRequestHandler hook - useEffect(() => { - const appEvents = getAppEvents(); - if (request.isSuccess && repository) { - onDismiss?.(); - - appEvents.publish({ - type: AppEvents.alertSuccess.name, - payload: [ - t( - 'browse-dashboards.new-provisioned-folder-form.alert-folder-created-successfully', - 'Folder created successfully' - ), - ], + const onBranchSuccess = ({ urls }: { urls?: Record }, info: ProvisionedOperationInfo) => { + const prUrl = urls?.newPullRequestURL; + if (prUrl) { + const url = buildResourceBranchRedirectUrl({ + paramName: 'new_pull_request_url', + paramValue: prUrl, + repoType: info.repoType, }); + navigate(url); + } + }; - const prUrl = request.data?.urls?.newPullRequestURL; - if (workflow === 'branch' && prUrl) { - const url = buildResourceBranchRedirectUrl({ - paramName: 'new_pull_request_url', - paramValue: prUrl, - repoType: request.data?.repository?.type, - }); - navigate(url); - return; - } - - // TODO: Update when the upsert type is fixed - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const folder = request.data.resource?.upsert as Resource; - if (folder?.metadata?.name) { - navigate(`/dashboards/f/${folder?.metadata?.name}/`); - return; - } + const onWriteSuccess = (resource: Resource) => { + // Navigation for new folders (resource-specific concern) + if (resource?.metadata?.name) { + navigate(`/dashboards/f/${resource.metadata.name}/`); + return; + } + // Fallback to provisioning URL + if (repository?.name && request.data?.path) { let url = `${PROVISIONING_URL}/${repository.name}/file/${request.data.path}`; if (request.data.ref?.length) { url += '?ref=' + request.data.ref; } navigate(url); - } else if (request.isError) { - appEvents.publish({ - type: AppEvents.alertError.name, - payload: [ - t('browse-dashboards.new-provisioned-folder-form.alert-error-creating-folder', 'Error creating folder'), - request.error, - ], - }); } - }, [request.isSuccess, request.isError, request.error, ref, request.data, workflow, navigate, repository, onDismiss]); + }; + + const onError = (error: unknown) => { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t('browse-dashboards.new-provisioned-folder-form.alert-error-creating-folder', 'Error creating folder'), + error, + ], + }); + }; + + // Use the repository-type and resource-type aware provisioned request handler + useProvisionedRequestHandler({ + request, + workflow, + repository, + resourceType: 'folder', + handlers: { + onDismiss, + onBranchSuccess, + onWriteSuccess: (_, resource) => onWriteSuccess(resource), + onError, + }, + }); const doSave = async ({ ref, title, workflow, comment }: BaseProvisionedFormData) => { const repoName = repository?.name; diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts index 4ac6515c726..7b5d17a2825 100644 --- a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -1,5 +1,6 @@ import { Chance } from 'chance'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { DashboardViewItem } from 'app/features/search/types'; import { DashboardsTreeItem, UIDashboardViewItem } from '../types'; @@ -74,7 +75,7 @@ export function sharedWithMeFolder(seed = 1): DashboardsTreeItem { - const onZoom = (zoomPanPinchRef: ReactZoomPanPinchRef) => { - const scale = zoomPanPinchRef.state.scale; - scene.scale = scale; - - if (scene.shouldInfinitePan) { - const isScaleZoomedOut = scale < 1; - - if (isScaleZoomedOut) { - scene.updateSize(scene.width / scale, scene.height / scale); - scene.panel.forceUpdate(); - } - } - }; - - const onZoomStop = (zoomPanPinchRef: ReactZoomPanPinchRef) => { - const scale = zoomPanPinchRef.state.scale; - scene.scale = scale; - updateMoveable(scale); - }; - - const onTransformed = ( - _: ReactZoomPanPinchRef, - state: { - scale: number; - positionX: number; - positionY: number; - } - ) => { - const scale = state.scale; - scene.scale = scale; - updateMoveable(scale); - }; - - const updateMoveable = (scale: number) => { - if (scene.moveable && scale > 0) { - scene.moveable.zoom = 1 / scale; - if (scale === 1) { - scene.moveable.snappable = true; - } else { - scene.moveable.snappable = false; - } - } - }; - - const onPanning = (_: ReactZoomPanPinchRef, event: MouseEvent | TouchEvent) => { - if (scene.shouldInfinitePan && event instanceof MouseEvent) { - // Get deltaX and deltaY from pan event and add it to current canvas dimensions - let deltaX = event.movementX; - let deltaY = event.movementY; - if (deltaX > 0) { - deltaX = 0; - } - if (deltaY > 0) { - deltaY = 0; - } - - // TODO: Consider bounding to the scene elements instead of allowing "infinite" panning - // TODO: Consider making scene grow in all directions vs just down to the right / bottom - scene.updateSize(scene.width - deltaX, scene.height - deltaY); - scene.panel.forceUpdate(); - } - }; - - const onSceneContainerMouseDown = (e: React.MouseEvent) => { - // If pan and zoom is disabled or context menu is visible, don't pan - if ((!scene.shouldPanZoom || scene.contextMenuVisible) && (e.button === 1 || (e.button === 2 && e.ctrlKey))) { - e.preventDefault(); - e.stopPropagation(); - } - - // If context menu is hidden, ignore left mouse or non-ctrl right mouse for pan - if (!scene.contextMenuVisible && !scene.isPanelEditing && e.button === 2 && !e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - } - }; - - // Set panel content overflow to hidden to prevent canvas content from overflowing - scene.div?.parentElement?.parentElement?.parentElement?.parentElement?.setAttribute('style', `overflow: hidden`); - - return ( - - - {/* The
element has child elements that allow for mouse events, so we need to disable the linter rule */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
{sceneDiv}
- - - ); -}; diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 047d42a5e45..a64d004967b 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -10,10 +10,13 @@ import { ValueLinkConfig, OneClickMode, ActionModel, + ActionVariableInput, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { ConfirmModal } from '@grafana/ui'; +import { TooltipDisplayMode } from '@grafana/schema'; +import { ConfirmModal, VariablesInputModal } from '@grafana/ui'; import { LayerElement } from 'app/core/components/Layers/types'; +import { config } from 'app/core/config'; import { notFoundItem } from 'app/features/canvas/elements/notFound'; import { DimensionContext } from 'app/features/dimensions/context'; import { @@ -23,7 +26,13 @@ import { Placement, VerticalConstraint, } from 'app/plugins/panel/canvas/panelcfg.gen'; -import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils'; +import { + applyStyles, + getConnectionsByTarget, + getRowIndex, + isConnectionTarget, + removeStyles, +} from 'app/plugins/panel/canvas/utils'; import { getActions, getActionsDefaultField } from '../../actions/utils'; import { CanvasElementItem, CanvasElementOptions } from '../element'; @@ -58,7 +67,15 @@ export class ElementState implements LayerElement { // cached for tooltips/mousemove oneClickMode = OneClickMode.Off; - showConfirmation = false; + showActionConfirmation = false; + + showActionVarsModal = false; + actionVars: ActionVariableInput = {}; + + setActionVars = (vars: ActionVariableInput) => { + this.actionVars = vars; + this.forceUpdate(); + }; constructor( public item: CanvasElementItem, @@ -104,6 +121,10 @@ export class ElementState implements LayerElement { /** Use the configured options to update CSS style properties directly on the wrapper div **/ applyLayoutStylesToDiv(disablePointerEvents?: boolean) { + if (config.featureToggles.canvasPanelPanZoom) { + this.applyLayoutStylesToDiv2(disablePointerEvents); + return; + } if (this.isRoot()) { // Root supersedes layout engine and is always 100% width + height of panel return; @@ -214,34 +235,166 @@ export class ElementState implements LayerElement { this.sizeStyle = style; if (this.div) { - for (const key in this.sizeStyle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions - this.div.style[key as any] = (this.sizeStyle as any)[key]; - } + applyStyles(this.sizeStyle, this.div); // TODO: This is a hack, we should have a better way to handle this const elementType = this.options.type; if (!SVGElements.has(elementType)) { // apply styles to div if it's not an SVG element - for (const key in this.dataStyle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions - this.div.style[key as any] = (this.dataStyle as any)[key]; - } + applyStyles(this.dataStyle, this.div); } else { // ELEMENT IS SVG // clean data styles from div if it's an SVG element; SVG elements have their own data styles; // this is necessary for changing type of element cases; // wrapper div element (this.div) doesn't re-render (has static `key` property), // so we have to clean styles manually; - for (const key in this.dataStyle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions - this.div.style[key as any] = ''; - } + removeStyles(this.dataStyle, this.div); } } } + /** Use the configured options to update CSS style properties directly on the wrapper div **/ + applyLayoutStylesToDiv2(disablePointerEvents?: boolean) { + if (this.isRoot()) { + // Root supersedes layout engine and is always 100% width + height of panel + return; + } + + const scene = this.getScene(); + const { width: sceneWidth, height: sceneHeight } = scene ?? {}; + + const { constraint } = this.options; + const { vertical, horizontal } = constraint ?? {}; + const placement: Placement = this.options.placement ?? {}; + + const editingEnabled = scene?.isEditingEnabled; + + const style: React.CSSProperties = { + cursor: editingEnabled ? 'grab' : 'auto', + pointerEvents: disablePointerEvents ? 'none' : 'auto', + position: 'absolute', + // Minimum element size is 10x10 + minWidth: '10px', + minHeight: '10px', + }; + + let transformY = '0px'; + let transformX = '0px'; + + switch (vertical) { + case VerticalConstraint.Top: + placement.top = placement.top ?? 0; + placement.height = placement.height ?? 100; + transformY = `${placement.top ?? 0}px`; + style.height = `${placement.height}px`; + delete placement.bottom; + break; + case VerticalConstraint.Bottom: + placement.bottom = placement.bottom ?? 0; + placement.height = placement.height ?? 100; + transformY = `${sceneHeight! - (placement.bottom ?? 0) - (placement.height ?? 100)}px`; + style.height = `${placement.height}px`; + delete placement.top; + break; + case VerticalConstraint.TopBottom: + placement.top = placement.top ?? 0; + placement.bottom = placement.bottom ?? 0; + transformY = `${placement.top ?? 0}px`; + style.height = `${sceneHeight! - (placement.top ?? 0) - (placement.bottom ?? 0)}px`; + delete placement.height; + break; + case VerticalConstraint.Center: + placement.top = placement.top ?? 0; + placement.height = placement.height ?? 100; + transformY = `${sceneHeight! / 2 - (placement.top ?? 0) - (placement.height ?? 0) / 2}px`; + style.height = `${placement.height}px`; + delete placement.bottom; + break; + case VerticalConstraint.Scale: + placement.top = placement.top ?? 0; + placement.bottom = placement.bottom ?? 0; + transformY = `${(placement.top ?? 0) * (sceneHeight! / 100)}px`; + style.height = `${sceneHeight! - (placement.top ?? 0) * (sceneHeight! / 100) - (placement.bottom ?? 0) * (sceneHeight! / 100)}px`; + delete placement.height; + break; + } + + switch (horizontal) { + case HorizontalConstraint.Left: + placement.left = placement.left ?? 0; + placement.width = placement.width ?? 100; + transformX = `${placement.left ?? 0}px`; + style.width = `${placement.width}px`; + delete placement.right; + break; + case HorizontalConstraint.Right: + placement.right = placement.right ?? 0; + placement.width = placement.width ?? 100; + transformX = `${sceneWidth! - (placement.right ?? 0) - (placement.width ?? 100)}px`; + style.width = `${placement.width}px`; + delete placement.left; + break; + case HorizontalConstraint.LeftRight: + placement.left = placement.left ?? 0; + placement.right = placement.right ?? 0; + transformX = `${placement.left ?? 0}px`; + style.width = `${sceneWidth! - (placement.left ?? 0) - (placement.right ?? 0)}px`; + delete placement.width; + break; + case HorizontalConstraint.Center: + placement.left = placement.left ?? 0; + placement.width = placement.width ?? 100; + transformX = `${sceneWidth! / 2 - (placement.left ?? 0) - (placement.width ?? 0) / 2}px`; + style.width = `${placement.width}px`; + delete placement.right; + break; + case HorizontalConstraint.Scale: + placement.left = placement.left ?? 0; + placement.right = placement.right ?? 0; + transformX = `${(placement.left ?? 0) * (sceneWidth! / 100)}px`; + style.width = `${sceneWidth! - (placement.left ?? 0) * (sceneWidth! / 100) - (placement.right ?? 0) * (sceneWidth! / 100)}px`; + delete placement.width; + break; + } + this.options.placement = placement; + style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`; + this.sizeStyle = style; + + if (this.div) { + applyStyles(this.sizeStyle, this.div); + + // TODO: This is a hack, we should have a better way to handle this + const elementType = this.options.type; + if (!SVGElements.has(elementType)) { + // apply styles to div if it's not an SVG element + applyStyles(this.dataStyle, this.div); + } else { + // ELEMENT IS SVG + // clean data styles from div if it's an SVG element; SVG elements have their own data styles; + // this is necessary for changing type of element cases; + // wrapper div element (this.div) doesn't re-render (has static `key` property), + // so we have to clean styles manually; + removeStyles(this.dataStyle, this.div); + } + } + } + + getTopLeftValues(element: Element) { + const style = window.getComputedStyle(element); + const matrix = new DOMMatrix(style.transform || ''); + return { + left: matrix.m41, + top: matrix.m42, + width: style.width ? parseFloat(style.width) : element.clientWidth, + height: style.height ? parseFloat(style.height) : element.clientHeight, + }; // m41 = translateX, m42 = translateY + } + setPlacementFromConstraint(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) { + if (config.featureToggles.canvasPanelPanZoom) { + this.setPlacementFromConstraint2(elementContainer, parentContainer, transformScale); + return; + } const { constraint } = this.options; const { vertical, horizontal } = constraint ?? {}; @@ -379,6 +532,101 @@ export class ElementState implements LayerElement { this.getScene()?.save(); } + setPlacementFromConstraint2(elementContainer?: DOMRect, parentContainer?: DOMRect, transformScale = 1) { + const scene = this.getScene()!; + const { constraint } = this.options; + const { vertical, horizontal } = constraint ?? {}; + + const elementRect = this.getTopLeftValues(this.div!); + + if (!elementContainer) { + elementContainer = this.div && this.div.getBoundingClientRect(); + } + // let parentBorderWidth = 0; + if (!parentContainer) { + parentContainer = this.div && this.div.parentElement?.getBoundingClientRect(); + } + + const relativeTop = Math.round(elementRect.top); + const relativeBottom = Math.round(scene.height - elementRect.top - elementRect.height); + const relativeLeft = Math.round(elementRect.left); + const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width); + + const placement: Placement = {}; + + const width = elementRect.width; + const height = elementRect.height; + + // INFO: calculate it anyway to be able to use it for pan&zoom + placement.top = relativeTop; + placement.left = relativeLeft; + + switch (vertical) { + case VerticalConstraint.Top: + placement.top = relativeTop; + placement.height = height; + break; + case VerticalConstraint.Bottom: + placement.bottom = relativeBottom; + placement.height = height; + break; + case VerticalConstraint.TopBottom: + placement.top = relativeTop; + placement.bottom = relativeBottom; + break; + case VerticalConstraint.Center: + const elementCenter = elementContainer ? relativeTop + height / 2 : 0; + const parentCenter = scene.height / 2; // Use scene height instead of scaled viewport height + const distanceFromCenter = parentCenter - elementCenter; + placement.top = distanceFromCenter; + placement.height = height; + break; + case VerticalConstraint.Scale: + placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale; + placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale; + break; + } + + switch (horizontal) { + case HorizontalConstraint.Left: + placement.left = relativeLeft; + placement.width = width; + break; + case HorizontalConstraint.Right: + placement.right = relativeRight; + placement.width = width; + break; + case HorizontalConstraint.LeftRight: + placement.left = relativeLeft; + placement.right = relativeRight; + break; + case HorizontalConstraint.Center: + const elementCenter = elementContainer ? relativeLeft + width / 2 : 0; + const parentCenter = scene.width / 2; // Use scene width instead of scaled viewport width + const distanceFromCenter = parentCenter - elementCenter; + placement.left = distanceFromCenter; + placement.width = width; + break; + case HorizontalConstraint.Scale: + placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale; + placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale; + break; + } + + if (this.options.placement?.rotation) { + placement.rotation = this.options.placement.rotation; + placement.width = this.options.placement.width; + placement.height = this.options.placement.height; + } + + this.options.placement = placement; + + this.applyLayoutStylesToDiv(); + this.revId++; + + this.getScene()?.save(); + } + updateData(ctx: DimensionContext) { if (this.item.prepareData) { this.data = this.item.prepareData(ctx, this.options); @@ -394,6 +642,8 @@ export class ElementState implements LayerElement { this.oneClickMode = OneClickMode.Link; } else if (this.options.actions?.some((action) => action.oneClick === true)) { this.oneClickMode = OneClickMode.Action; + } else { + this.oneClickMode = OneClickMode.Off; } if (frames) { @@ -564,12 +814,12 @@ export class ElementState implements LayerElement { // kinda like: // https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44 - applyResize = (event: OnResize, transformScale = 1) => { + applyResize = (event: OnResize) => { const placement = this.options.placement!; const style = event.target.style; - let deltaX = event.delta[0] / transformScale; - let deltaY = event.delta[1] / transformScale; + let deltaX = event.delta[0]; + let deltaY = event.delta[1]; let dirLR = event.direction[0]; let dirTB = event.direction[1]; @@ -590,14 +840,22 @@ export class ElementState implements LayerElement { } else if (dirLR === -1) { placement.left! -= deltaX; placement.width = event.width; - style.left = `${placement.left}px`; + if (config.featureToggles.canvasPanelPanZoom) { + style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`; + } else { + style.left = `${placement.left}px`; + } style.width = `${placement.width}px`; } if (dirTB === -1) { placement.top! -= deltaY; placement.height = event.height; - style.top = `${placement.top}px`; + if (config.featureToggles.canvasPanelPanZoom) { + style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`; + } else { + style.top = `${placement.top}px`; + } style.height = `${placement.height}px`; } else if (dirTB === 1) { placement.height = event.height; @@ -608,7 +866,7 @@ export class ElementState implements LayerElement { handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => { const scene = this.getScene(); - const shouldHandleTooltip = !scene?.isEditingEnabled && !scene?.tooltip?.isOpen; + const shouldHandleTooltip = !scene?.isEditingEnabled && !scene?.tooltipPayload?.isOpen; if (shouldHandleTooltip) { this.handleTooltip(event); } else if (!isSelected) { @@ -675,7 +933,7 @@ export class ElementState implements LayerElement { handleTooltip = (event: React.MouseEvent) => { const scene = this.getScene(); - if (scene?.tooltipCallback) { + if (scene?.tooltipCallback && scene.tooltipMode !== TooltipDisplayMode.None) { const rect = this.div?.getBoundingClientRect(); scene.tooltipCallback({ anchorPoint: { x: rect?.right ?? event.pageX, y: rect?.top ?? event.pageY }, @@ -687,7 +945,7 @@ export class ElementState implements LayerElement { handleMouseLeave = (event: React.MouseEvent) => { const scene = this.getScene(); - if (scene?.tooltipCallback && !scene?.tooltip?.isOpen) { + if (scene?.tooltipCallback && !scene?.tooltipPayload?.isOpen) { scene.tooltipCallback(undefined); } @@ -705,8 +963,16 @@ export class ElementState implements LayerElement { window.open(primaryDataLink.href, primaryDataLink.target ?? '_self'); } } else if (this.oneClickMode === OneClickMode.Action) { - this.showConfirmation = true; - this.forceUpdate(); + const primaryAction = this.getPrimaryAction(); + const actionHasVariables = primaryAction?.variables && primaryAction.variables.length > 0; + + if (actionHasVariables) { + this.showActionVarsModal = true; + this.forceUpdate(); + } else { + this.showActionConfirmation = true; + this.forceUpdate(); + } } else { this.handleTooltip(event); this.onTooltipCallback(); @@ -725,9 +991,9 @@ export class ElementState implements LayerElement { onTooltipCallback = () => { const scene = this.getScene(); - if (scene?.tooltipCallback && scene.tooltip?.anchorPoint) { + if (scene?.tooltipCallback && scene.tooltipPayload?.anchorPoint) { scene.tooltipCallback({ - anchorPoint: { x: scene.tooltip.anchorPoint.x, y: scene.tooltip.anchorPoint.y }, + anchorPoint: { x: scene.tooltipPayload.anchorPoint.x, y: scene.tooltipPayload.anchorPoint.y }, element: this, isOpen: true, }); @@ -748,7 +1014,7 @@ export class ElementState implements LayerElement { return ( <> - {this.showConfirmation && action && ( + {this.showActionConfirmation && action && ( { - this.showConfirmation = false; - action.onClick(new MouseEvent('click')); + this.showActionConfirmation = false; + action.onClick(new MouseEvent('click'), null, this.actionVars); this.forceUpdate(); }} onDismiss={() => { - this.showConfirmation = false; + this.showActionConfirmation = false; this.forceUpdate(); }} /> @@ -770,6 +1036,31 @@ export class ElementState implements LayerElement { ); }; + renderVariablesInputModal = (action: ActionModel | undefined) => { + if (!action || !action.variables || action.variables.length === 0) { + return; + } + + const onModalContinue = () => { + this.showActionVarsModal = false; + this.showActionConfirmation = true; + this.forceUpdate(); + }; + + return ( + { + this.showActionVarsModal = false; + this.forceUpdate(); + }} + onShowConfirm={onModalContinue} + /> + ); + }; + render() { const { item, div } = this; const scene = this.getScene(); @@ -786,6 +1077,7 @@ export class ElementState implements LayerElement { onKeyDown={!scene?.isEditingEnabled ? this.onElementKeyDown : undefined} role="button" tabIndex={0} + style={{ userSelect: 'none' }} >
- {this.showConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())} + {this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())} + {this.showActionVarsModal && this.renderVariablesInputModal(this.getPrimaryAction())} ); } diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 5e54afd9be8..7495d74ff88 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; +import InfiniteViewer from 'infinite-viewer'; import Moveable from 'moveable'; -import { createRef, CSSProperties, RefObject } from 'react'; -import { ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'; +import { CSSProperties } from 'react'; import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs'; import Selecto from 'selecto'; @@ -13,6 +13,7 @@ import { ScalarDimensionConfig, ScaleDimensionConfig, TextDimensionConfig, + TooltipDisplayMode, } from '@grafana/schema'; import { Portal } from '@grafana/ui'; import { config } from 'app/core/config'; @@ -27,19 +28,20 @@ import { import { CanvasContextMenu } from 'app/plugins/panel/canvas/components/CanvasContextMenu'; import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip'; import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections'; +import { Connections2 } from 'app/plugins/panel/canvas/components/connections/Connections2'; +import { Options } from 'app/plugins/panel/canvas/panelcfg.gen'; import { AnchorPoint, CanvasTooltipPayload } from 'app/plugins/panel/canvas/types'; -import { getTransformInstance } from 'app/plugins/panel/canvas/utils'; import appEvents from '../../../core/app_events'; import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel'; +import { getDashboardSrv } from '../../dashboard/services/DashboardSrv'; import { CanvasFrameOptions } from '../frame'; import { DEFAULT_CANVAS_ELEMENT_CONFIG } from '../registry'; -import { SceneTransformWrapper } from './SceneTransformWrapper'; import { ElementState } from './element'; import { FrameState } from './frame'; import { RootElement } from './root'; -import { initMoveable } from './sceneAbleManagement'; +import { initMoveable, calculateZoomToFitScale } from './sceneAbleManagement'; import { findElementByTarget } from './sceneElementManagement'; export interface SelectionParams { @@ -60,31 +62,30 @@ export class Scene { width = 0; height = 0; scale = 1; + scrollLeft = 0; + scrollTop = 0; style: CSSProperties = {}; data?: PanelData; selecto?: Selecto; moveable?: Moveable; + infiniteViewer?: InfiniteViewer; div?: HTMLDivElement; - connections: Connections; + viewerDiv?: HTMLDivElement; + viewportDiv?: HTMLDivElement; + connections: Connections | Connections2; currentLayer?: FrameState; isEditingEnabled?: boolean; shouldShowAdvancedTypes?: boolean; shouldPanZoom?: boolean; - shouldInfinitePan?: boolean; + zoomToContent?: boolean; + tooltipMode?: TooltipDisplayMode; skipNextSelectionBroadcast = false; ignoreDataUpdate = false; panel: CanvasPanel; contextMenuVisible?: boolean; + openContextMenu?: (position: AnchorPoint) => void; contextMenuOnVisibilityChange = (visible: boolean) => { this.contextMenuVisible = visible; - const transformInstance = getTransformInstance(this); - if (transformInstance) { - if (visible) { - transformInstance.setup.disabled = true; - } else { - transformInstance.setup.disabled = false; - } - } }; isPanelEditing = locationService.getSearchObject().editPanel !== undefined; @@ -93,7 +94,7 @@ export class Scene { setBackgroundCallback?: (anchorPoint: AnchorPoint) => void; tooltipCallback?: (tooltip: CanvasTooltipPayload | undefined) => void; - tooltip?: CanvasTooltipPayload; + tooltipPayload?: CanvasTooltipPayload; moveableActionCallback?: (moved: boolean) => void; @@ -103,18 +104,18 @@ export class Scene { subscription: Subscription; targetsToSelect = new Set(); - transformComponentRef: RefObject | undefined; constructor( - cfg: CanvasFrameOptions, - enableEditing: boolean, - showAdvancedTypes: boolean, - panZoom: boolean, - infinitePan: boolean, + options: Options, public onSave: (cfg: CanvasFrameOptions) => void, panel: CanvasPanel ) { - this.root = this.load(cfg, enableEditing, showAdvancedTypes, panZoom, infinitePan); + // TODO: Will need to update this approach for dashboard scenes + // migration (new dashboard edit experience) + const dashboard = getDashboardSrv().getCurrent(); + const enableEditing = options.inlineEditing && dashboard?.editable; + + this.root = this.load(options, enableEditing); this.subscription = this.editModeEnabled.subscribe((open) => { if (!this.moveable || !this.isEditingEnabled) { @@ -124,8 +125,7 @@ export class Scene { }); this.panel = panel; - this.connections = new Connections(this); - this.transformComponentRef = createRef(); + this.connections = config.featureToggles.canvasPanelPanZoom ? new Connections2(this) : new Connections(this); } getNextElementName = (isFrame = false) => { @@ -147,15 +147,12 @@ export class Scene { return !this.byName.has(v); }; - load( - cfg: CanvasFrameOptions, - enableEditing: boolean, - showAdvancedTypes: boolean, - panZoom: boolean, - infinitePan: boolean - ) { + load(options: Options, enableEditing: boolean) { + const { root, showAdvancedTypes, panZoom, zoomToContent, tooltip } = options; + const tooltipMode = tooltip?.mode ?? TooltipDisplayMode.Single; + this.root = new RootElement( - cfg ?? { + root ?? { type: 'frame', elements: [DEFAULT_CANVAS_ELEMENT_CONFIG], }, @@ -166,17 +163,39 @@ export class Scene { this.isEditingEnabled = enableEditing; this.shouldShowAdvancedTypes = showAdvancedTypes; this.shouldPanZoom = panZoom; - this.shouldInfinitePan = infinitePan; + this.zoomToContent = zoomToContent; + this.tooltipMode = tooltipMode; setTimeout(() => { - if (this.div) { - // If editing is enabled, clear selecto instance - const destroySelecto = enableEditing; - initMoveable(destroySelecto, enableEditing, this); - this.currentLayer = this.root; - this.selection.next([]); - this.connections.select(undefined); - this.connections.updateState(); + if (config.featureToggles.canvasPanelPanZoom) { + if (this.viewportDiv && this.viewerDiv) { + if (!this.shouldPanZoom) { + this.scale = 1; + this.scrollLeft = 0; + this.scrollTop = 0; + } + + // If editing is enabled, clear selecto instance + const destroySelecto = enableEditing; + initMoveable(destroySelecto, enableEditing, this); + this.currentLayer = this.root; + this.selection.next([]); + this.connections.select(undefined); + this.connections.updateState(); + // update initial connections svg size + this.updateConnectionsSize(); + this.fitContent(this, zoomToContent); + } + } else { + if (this.div) { + // If editing is enabled, clear selecto instance + const destroySelecto = enableEditing; + initMoveable(destroySelecto, enableEditing, this); + this.currentLayer = this.root; + this.selection.next([]); + this.connections.select(undefined); + this.connections.updateState(); + } } }); return this.root; @@ -204,12 +223,53 @@ export class Scene { if (this.selecto?.getSelectedTargets().length) { this.clearCurrentSelection(); } + + if (config.featureToggles.canvasPanelPanZoom) { + this.updateConnectionsSize(); + this.fitContent(this, this.zoomToContent!); + + // TODO: This is a workaround to apply styles to the elements after the size update. + // It's a good to go approach used by movable creator, but maybe we can find a better way. + this.root.elements.forEach((el) => { + el.applyLayoutStylesToDiv(false); + }); + // TODO: This is a workaround to apply styles to the elements after the size update. + // Remove this after dealing with the connection anchors stacking context issue. + if (this.connections.connectionAnchorDiv) { + this.connections.connectionAnchorDiv.style.display = 'none'; + } + } + } + + updateConnectionsSize() { + const svgConnections = this.connections.connectionsSVG; + + if (svgConnections) { + const scale = this.infiniteViewer!.getZoom(); + // NOTE: sometimes getScrollLeft and getScrollTop return NaN, + // so we use || 0 to ensure we have a valid number + const left = this.infiniteViewer!.getScrollLeft() || 0; + const top = this.infiniteViewer!.getScrollTop() || 0; + const width = this.width; + const height = this.height; + + svgConnections.style.left = `${left}px`; + svgConnections.style.top = `${top}px`; + svgConnections.style.width = `${width / scale}px`; + svgConnections.style.height = `${height / scale}px`; + + svgConnections.setAttribute('viewBox', `${left} ${top} ${width / scale} ${height / scale}`); + } } clearCurrentSelection(skipNextSelectionBroadcast = false) { this.skipNextSelectionBroadcast = skipNextSelectionBroadcast; let event: MouseEvent = new MouseEvent('click'); - this.selecto?.clickTarget(event, this.div); + if (config.featureToggles.canvasPanelPanZoom) { + this.selecto?.clickTarget(event, this.viewportDiv); + } else { + this.selecto?.clickTarget(event, this.div); + } } save = (updateMoveable = false) => { @@ -217,8 +277,15 @@ export class Scene { if (updateMoveable) { setTimeout(() => { - if (this.div) { - initMoveable(true, this.isEditingEnabled, this); + if (config.featureToggles.canvasPanelPanZoom) { + if (this.viewportDiv && this.viewerDiv) { + initMoveable(true, this.isEditingEnabled, this); + this.updateConnectionsSize(); + } + } else { + if (this.div) { + initMoveable(true, this.isEditingEnabled, this); + } } }); } @@ -244,6 +311,14 @@ export class Scene { this.div = sceneContainer; }; + setViewerRef = (viewerContainer: HTMLDivElement) => { + this.viewerDiv = viewerContainer; + }; + + setViewportRef = (viewportContainer: HTMLDivElement) => { + this.viewportDiv = viewportContainer; + }; + select = (selection: SelectionParams) => { if (this.selecto) { this.selecto.setSelectedTargets(selection.targets); @@ -282,15 +357,27 @@ export class Scene { } }; - render() { - const hasDataLinks = this.tooltip?.element?.getLinks && this.tooltip.element.getLinks({}).length > 0; - const hasActions = this.tooltip?.element?.options.actions && this.tooltip.element.options.actions.length > 0; + fitContent = (scene: Scene, zoomToContent: boolean) => { + const { root, viewerDiv, infiniteViewer } = scene; + if (zoomToContent && root.div && infiniteViewer && viewerDiv) { + const dimentions = calculateZoomToFitScale(Array.from(root.div.children), viewerDiv); + const { scale, centerX, centerY } = dimentions; + infiniteViewer.setZoom(scale); + infiniteViewer.scrollTo(centerX, centerY); + } + }; - const isTooltipValid = hasDataLinks || hasActions || this.tooltip?.element?.data?.field; - const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid; + render() { + const hasDataLinks = this.tooltipPayload?.element?.getLinks && this.tooltipPayload.element.getLinks({}).length > 0; + const hasActions = + this.tooltipPayload?.element?.options.actions && this.tooltipPayload.element.options.actions.length > 0; + + const isTooltipValid = hasDataLinks || hasActions || this.tooltipPayload?.element?.data?.field; + const isTooltipEnabled = this.tooltipMode !== TooltipDisplayMode.None; + const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid && isTooltipEnabled; const sceneDiv = ( -
+ <> {this.connections.render()} {this.root.render()} {this.isEditingEnabled && ( @@ -307,13 +394,30 @@ export class Scene { )} -
+ ); return config.featureToggles.canvasPanelPanZoom ? ( - {sceneDiv} +
+
+ {sceneDiv} +
+
) : ( - sceneDiv +
+ {sceneDiv} +
); } } @@ -323,4 +427,16 @@ const getStyles = () => ({ overflow: 'hidden', position: 'relative', }), + selected: css({ + zIndex: '999 !important', + }), + viewer: css({ + overflow: 'hidden', + width: '100%', + height: '100%', + }), + viewport: css({ + width: '100%', + height: '100%', + }), }); diff --git a/public/app/features/canvas/runtime/sceneAbleManagement.ts b/public/app/features/canvas/runtime/sceneAbleManagement.ts index 0befab40297..9af3e1a74e0 100644 --- a/public/app/features/canvas/runtime/sceneAbleManagement.ts +++ b/public/app/features/canvas/runtime/sceneAbleManagement.ts @@ -1,13 +1,14 @@ +import InfiniteViewer from 'infinite-viewer'; import Moveable from 'moveable'; import Selecto from 'selecto'; +import { config } from 'app/core/config'; import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; import { CONNECTION_VERTEX_ID, CONNECTION_VERTEX_ADD_ID, } from 'app/plugins/panel/canvas/components/connections/Connections'; import { VerticalConstraint, HorizontalConstraint } from 'app/plugins/panel/canvas/panelcfg.gen'; -import { getParent } from 'app/plugins/panel/canvas/utils'; import { dimensionViewable, constraintViewable, settingsViewable } from './ables'; import { ElementState } from './element'; @@ -15,6 +16,8 @@ import { FrameState } from './frame'; import { Scene } from './scene'; import { findElementByTarget } from './sceneElementManagement'; +const ZOOM_RANGE = [0.1, 4]; // Minimum zoom 0.1x (10%), maximum zoom 4x (400%) + // Helper function that disables custom able functionality const disableCustomables = (moveable: Moveable) => { moveable!.props = { @@ -95,8 +98,8 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: } scene.selecto = new Selecto({ - container: scene.div, - rootContainer: getParent(scene), + rootContainer: config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv : scene.div, + dragContainer: config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv : scene.div, selectableTargets: targetElements, toggleContinueSelect: 'shift', selectFromInside: false, @@ -106,7 +109,7 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true }; - scene.moveable = new Moveable(scene.div!, { + scene.moveable = new Moveable(config.featureToggles.canvasPanelPanZoom ? scene.viewerDiv! : scene.div!, { draggable: allowChanges && !scene.editModeEnabled.getValue(), resizable: allowChanges, @@ -137,6 +140,12 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: if (targetedElement) { targetedElement.applyRotate(event); + + if (config.featureToggles.canvasPanelPanZoom) { + if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) { + scene.moveableActionCallback(true); + } + } } }) .on('rotateGroup', (e) => { @@ -221,9 +230,7 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: e.events.forEach((event) => { const targetedElement = findElementByTarget(event.target, scene.root.elements); if (targetedElement) { - if (targetedElement) { - targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); - } + targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); // re-add the selected elements to the snappable guidelines if (scene.moveable && scene.moveable.elementGuidelines) { @@ -280,11 +287,23 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: } } } + // Temporarily set Top-Left constraints on each group element for predictable resizing; restore originals on end. + for (let event of e.events) { + const targetedElement = findElementByTarget(event.target, scene.root.elements); + if (targetedElement) { + targetedElement.tempConstraint = { ...targetedElement.options.constraint }; + targetedElement.options.constraint = { + vertical: VerticalConstraint.Top, + horizontal: HorizontalConstraint.Left, + }; + targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); + } + } }) .on('resize', (event) => { const targetedElement = findElementByTarget(event.target, scene.root.elements); if (targetedElement) { - targetedElement.applyResize(event, scene.scale); + targetedElement.applyResize(event); if (scene.connections.connectionsNeedUpdate(targetedElement) && scene.moveableActionCallback) { scene.moveableActionCallback(true); @@ -319,7 +338,6 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: targetedElement.options.constraint = targetedElement.tempConstraint; targetedElement.tempConstraint = undefined; } - targetedElement.setPlacementFromConstraint(undefined, undefined, scene.scale); // re-add the selected element to the snappable guidelines @@ -409,4 +427,194 @@ export const initMoveable = (destroySelecto = false, allowChanges = true, scene: .on('dragEnd', (event) => { clearTimeout(event.data.timer); }); + + if (config.featureToggles.canvasPanelPanZoom) { + /******************/ + /* infiniteViewer */ + /******************/ + scene.infiniteViewer = new InfiniteViewer(scene.viewerDiv!, scene.viewportDiv!, { + preventWheelClick: false, + useAutoZoom: true, + useMouseDrag: false, // `true` blocks metricValue dropdown + useWheelScroll: scene.shouldPanZoom, + displayHorizontalScroll: false, + displayVerticalScroll: false, + zoomRange: ZOOM_RANGE, + }); + scene.infiniteViewer.setZoom(scene.scale); + scene.infiniteViewer.scrollTo(scene.scrollLeft, scene.scrollTop); + + // Handles context menu activation + // Uses openContextMenu with coordinates when available (after CanvasContextMenu mounts), but + // uses the basic visibility toggle when openContextMenu isn't ready (as a fallback) + const triggerContextMenu = (x: number, y: number) => { + if (scene.openContextMenu) { + scene.openContextMenu({ x, y }); + } else { + scene.contextMenuOnVisibilityChange(true); + } + }; + + /* ----------------------------- EVENT HANDLERS ----------------------------- */ + // Helper for panning with mouse drag (middle mouse or Ctrl+right-click) + // TODO: It was implemented as a workaround to unblock left click metricsValue dropdown, + // but it should be replaced with a more robust solution that doesn't interfere with left click interactions. + function startPanning(e: MouseEvent) { + e.preventDefault(); + + const startX = e.clientX; + const startY = e.clientY; + const startScrollLeft = scene.infiniteViewer!.getScrollLeft(); + const startScrollTop = scene.infiniteViewer!.getScrollTop(); + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = startX - moveEvent.clientX; + const deltaY = startY - moveEvent.clientY; + const scaleAdjustedDeltaX = deltaX / scene.scale; + const scaleAdjustedDeltaY = deltaY / scene.scale; + scene.infiniteViewer!.scrollTo(startScrollLeft + scaleAdjustedDeltaX, startScrollTop + scaleAdjustedDeltaY); + moveEvent.preventDefault(); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + // Right click + scene.viewerDiv!.addEventListener('contextmenu', (e) => { + if (e.ctrlKey && e.button === 2 && scene.shouldPanZoom) { + // Enable panning with Ctrl+right-click + startPanning(e); + } else { + // Prevent default browser context menu + e.preventDefault(); + triggerContextMenu(e.pageX, e.pageY); + } + }); + + // Enable panning with middle mouse button (wheel button) + scene.viewerDiv!.addEventListener('mousedown', (e: MouseEvent) => { + if (e.button === 1 && scene.shouldPanZoom) { + // Middle mouse button + startPanning(e); + } + }); + + // Prevent wheel scrolling when pan/zoom is disabled + scene.viewportDiv!.addEventListener( + 'wheel', + (e) => { + if (!scene.shouldPanZoom) { + e.stopImmediatePropagation(); + e.preventDefault(); + } + }, + { passive: false } + ); + + // Reset zoom and scroll position on double click + scene.viewerDiv!.addEventListener('dblclick', (e: MouseEvent) => { + // Only reset if not in edit mode and pan/zoom is enabled + if (!scene.editModeEnabled.getValue() && scene.shouldPanZoom && scene.infiniteViewer) { + scene.infiniteViewer.setZoom(1); + scene.infiniteViewer.scrollTo(0, 0); + } + }); + + // Mouse scroll click + // Only allow panning with middle mouse button (button 1) + // Left click is reserved for selection/manipulation, right click for context menu + scene.infiniteViewer!.on('dragStart', (e) => { + if (e.inputEvent.button !== 1) { + e.preventDefault(); + e.preventDrag(); + } + }); + + // Scroll + scene.infiniteViewer!.on('scroll', () => { + // TODO: clear current selection is default behaviour on zoom-in or zoom-out, + // but looks like we prevented this event to trigger at some point + scene.clearCurrentSelection(true); + + scene.updateConnectionsSize(); + scene.scale = scene.infiniteViewer!.getZoom(); + + scene.scrollLeft = scene.infiniteViewer!.getScrollLeft(); + scene.scrollTop = scene.infiniteViewer!.getScrollTop(); + }); + } }; + +// Zoom to content helper functions +export function calculateZoomToFitScale(elements: Element[], container: HTMLDivElement, paddingRatio = 0.05) { + const bounds = calculateGroupBoundingBox(elements); + const containerRect = container.getBoundingClientRect(); + const containerWidth = containerRect.width; + const containerHeight = containerRect.height; + + const paddedWidth = containerWidth * (1 - 2 * paddingRatio); + const paddedHeight = containerHeight * (1 - 2 * paddingRatio); + + const scaleX = paddedWidth / bounds.width; + const scaleY = paddedHeight / bounds.height; + + // Use the smaller one to fit both horizontally and vertically + const scale = Math.min(scaleX, scaleY); + + // calculate value to move to center + const centerX = (bounds.centerX * scale - containerWidth / 2) / scale; + const centerY = (bounds.centerY * scale - containerHeight / 2) / scale; + + return { + scale, + centerX, + centerY, + }; +} + +export function extractTranslateFromTransform(transform: string) { + const matrix = new DOMMatrix(transform); + return { x: matrix.m41, y: matrix.m42 }; // m41 = translateX, m42 = translateY +} + +export function calculateGroupBoundingBox(elements: Element[]) { + let minX = Infinity, + minY = Infinity; + let maxX = -Infinity, + maxY = -Infinity; + + for (const el of elements) { + const style = window.getComputedStyle(el); + const { x: tx, y: ty } = extractTranslateFromTransform(style.transform || ''); + + const width = parseFloat(style.width); + const height = parseFloat(style.height); + + const left = tx; + const top = ty; + const right = tx + width; + const bottom = ty + height; + + minX = Math.min(minX, left); + minY = Math.min(minY, top); + maxX = Math.max(maxX, right); + maxY = Math.max(maxY, bottom); + } + + return { + left: minX, + top: minY, + right: maxX, + bottom: maxY, + width: maxX - minX, + height: maxY - minY, + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2, + }; +} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index d91cf0d2230..63f1aaa0bcd 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -267,7 +267,7 @@ abstract class DashboardScenePageStateManagerBase const queryController = sceneGraph.getQueryController(dashboard); trackDashboardSceneLoaded(dashboard, measure?.duration); - queryController?.startProfile('DashboardScene'); + queryController?.startProfile('dashboard_view'); if (options.route !== DashboardRoutes.New) { emitDashboardViewEvent({ diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index ce2784758be..668b8612851 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -376,6 +376,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps @@ -393,7 +394,9 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps - openQueryLibraryDrawer(getDatasourceNames(datasource, queries), onSelectQueryFromLibrary) + openQueryLibraryDrawer(getDatasourceNames(datasource, queries), onSelectQueryFromLibrary, { + context: CoreApp.PanelEditor, + }) } variant="secondary" data-testid={selectors.components.QueryTab.addQueryFromLibrary} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 6071c79f41a..9aee611d4f0 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -195,6 +195,7 @@ function getStyles(theme: GrafanaTheme2) { position: 'absolute', width: '100%', height: '100%', + overflow: 'unset', }), body: css({ label: 'body', diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts index 6fc70618106..5b02068f80c 100644 --- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -46,20 +46,24 @@ export class DashboardSceneChangeTracker { } static isUpdatingPersistedState({ payload }: SceneObjectStateChangedEvent) { + const partialUpdateKeys = Object.keys(payload.partialUpdate); + // If there are no changes in the state, the check is not needed - if (Object.keys(payload.partialUpdate).length === 0) { + if (partialUpdateKeys.length === 0) { return false; } - // Any change in the panel should trigger a change detection + // Any change in the grid item should trigger a change detection // The PanelTimeRange includes the overrides configuration - if ( - payload.changedObject instanceof VizPanel || - payload.changedObject instanceof DashboardGridItem || - payload.changedObject instanceof PanelTimeRange - ) { + if (payload.changedObject instanceof DashboardGridItem || payload.changedObject instanceof PanelTimeRange) { return true; } + // Panels contain a _renderCounter state prop which should not be marked as a change + if (payload.changedObject instanceof VizPanel) { + if (partialUpdateKeys.length > 1 || partialUpdateKeys[0] !== '_renderCounter') { + return true; + } + } // SceneQueryRunner includes the DS configuration if (payload.changedObject instanceof SceneQueryRunner) { if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx index 4387af82439..260c491bb57 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx @@ -18,7 +18,7 @@ import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks import { ResourceEditFormSharedFields } from '../../components/Provisioned/ResourceEditFormSharedFields'; import { buildResourceBranchRedirectUrl } from '../../settings/utils'; import { getDashboardUrl } from '../../utils/getDashboardUrl'; -import { useProvisionedRequestHandler } from '../../utils/useProvisionedRequestHandler'; +import { ProvisionedOperationInfo, useProvisionedRequestHandler } from '../../utils/useProvisionedRequestHandler'; import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm'; import { ProvisionedDashboardFormData } from '../shared'; @@ -60,25 +60,15 @@ export function SaveProvisionedDashboardForm({ reset(defaultValues); }, [defaultValues, reset]); - const onRequestError = (error: unknown) => { + const onRequestError = (error: unknown, info: ProvisionedOperationInfo) => { appEvents.publish({ type: AppEvents.alertError.name, payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), error], }); }; - const onWriteSuccess = () => { - panelEditor?.onDiscard(); - drawer.onClose(); - locationService.partial({ - viewPanel: null, - editPanel: null, - }); - }; - - const onNewDashboardSuccess = (upsert: Resource) => { - panelEditor?.onDiscard(); - drawer.onClose(); + const handleNewDashboard = (upsert: Resource) => { + // Navigation for new dashboards const url = locationUtil.assureBaseUrl( getDashboardUrl({ uid: upsert.metadata.name, @@ -86,34 +76,50 @@ export function SaveProvisionedDashboardForm({ currentQueryParams: window.location.search, }) ); - navigate(url); }; - const onBranchSuccess = (ref: string, path: string) => { + const onWriteSuccess = (_: ProvisionedOperationInfo, upsert: Resource) => { + if (isNew && upsert?.metadata.name) { + handleNewDashboard(upsert); + } else { + locationService.partial({ + viewPanel: null, + editPanel: null, + }); + } + }; + + const onBranchSuccess = (ref: string, path: string, info: ProvisionedOperationInfo, upsert: Resource) => { + if (isNew && upsert?.metadata?.name) { + handleNewDashboard(upsert); + } else { + const url = buildResourceBranchRedirectUrl({ + baseUrl: `${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}`, + paramName: 'ref', + paramValue: ref, + repoType: info.repoType, + }); + navigate(url); + } + }; + + const onDismiss = () => { + dashboard.setState({ isDirty: false }); panelEditor?.onDiscard(); drawer.onClose(); - - const url = buildResourceBranchRedirectUrl({ - baseUrl: `${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}`, - paramName: 'ref', - paramValue: ref, - repoType: request.data?.repository?.type, - }); - navigate(url); }; - useProvisionedRequestHandler({ - dashboard, + useProvisionedRequestHandler({ request, workflow, + resourceType: 'dashboard', handlers: { - onBranchSuccess: ({ ref, path }) => onBranchSuccess(ref, path), + onBranchSuccess: ({ ref, path }, info, resource) => onBranchSuccess(ref, path, info, resource), onWriteSuccess, - onNewDashboardSuccess, onError: onRequestError, + onDismiss, }, - isNew, }); // Submit handler for saving the form data diff --git a/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.test.ts b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.test.ts new file mode 100644 index 00000000000..d55c4940f52 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.test.ts @@ -0,0 +1,74 @@ +import { getPanelPlugin } from '@grafana/data/test'; +import { setPluginImportUtils } from '@grafana/runtime'; +import { SceneTimeRange, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes'; + +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardLevelTimeMacro } from './DashboardLevelTimeMacro'; +import { DashboardScene } from './DashboardScene'; +import { PanelTimeRange } from './PanelTimeRange'; +import { AutoGridItem } from './layout-auto-grid/AutoGridItem'; +import { AutoGridLayout } from './layout-auto-grid/AutoGridLayout'; +import { + AutoGridLayoutManager, + getAutoRowsTemplate, + getTemplateColumnsTemplate, +} from './layout-auto-grid/AutoGridLayoutManager'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), +})); + +setPluginImportUtils({ + importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})), + getPanelPluginFromCache: (id: string) => undefined, +}); + +describe('dashboardLevelTimeMacros', () => { + it('Can use use $__from and $__to', async () => { + const panel = new VizPanel({ + $timeRange: new PanelTimeRange({ timeShift: '1h' }), + title: 'Test Panel', + key: 'panel-1', + pluginId: 'timeseries', + }); + + const scene = new DashboardScene({ + $timeRange: new SceneTimeRange({ from: '2023-05-23T06:09:57.073Z', to: '2023-05-23T07:09:57.073Z' }), + body: new AutoGridLayoutManager({ + maxColumnCount: 12, + columnWidth: 100, + rowHeight: 100, + fillScreen: true, + layout: new AutoGridLayout({ + isDraggable: true, + templateColumns: getTemplateColumnsTemplate(12, 100), + autoRows: getAutoRowsTemplate(100, true), + children: [ + new AutoGridItem({ + body: panel, + }), + ], + }), + }), + }); + + activateFullSceneTree(scene); + + // Wait for the scene to be activated + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(sceneGraph.interpolate(scene, '$__from')).toBe('1684822197073'); // Dashboard level time range + expect(sceneGraph.interpolate(scene, '$__to')).toBe('1684825797073'); // Dashboard level time range + + expect(sceneGraph.interpolate(panel, '$__from')).toBe('1684818597073'); // Time shifted by 1h + expect(sceneGraph.interpolate(panel, '$__to')).toBe('1684822197073'); // Time shifted by 1h + + sceneUtils.registerVariableMacro('__from', DashboardLevelTimeMacro, true); + sceneUtils.registerVariableMacro('__to', DashboardLevelTimeMacro, true); + + expect(sceneGraph.interpolate(panel, '$__from')).toBe('1684822197073'); // Dashboard level time range even when panel is time shifted + expect(sceneGraph.interpolate(panel, '$__to')).toBe('1684825797073'); // Dashboard level time range even when panel is time shifted + }); +}); diff --git a/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.ts b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.ts new file mode 100644 index 00000000000..e6d0fc032fb --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLevelTimeMacro.ts @@ -0,0 +1,33 @@ +import { dateTimeFormat } from '@grafana/data'; +import { FormatVariable, sceneGraph, SceneObject } from '@grafana/scenes'; + +/** + * This macro is used to support the old __to and __from macros that always used the dashboard level time range. + **/ +export class DashboardLevelTimeMacro implements FormatVariable { + public state: { name: string; type: string }; + private _sceneObject: SceneObject; + + public constructor(name: string, sceneObject: SceneObject) { + this.state = { name: name, type: 'time_macro' }; + this._sceneObject = sceneObject.getRoot(); + } + + public getValue() { + const timeRange = sceneGraph.getTimeRange(this._sceneObject); + if (this.state.name === '__from') { + return timeRange.state.value.from.valueOf(); + } else { + return timeRange.state.value.to.valueOf(); + } + } + + public getValueText?(): string { + const timeRange = sceneGraph.getTimeRange(this._sceneObject); + if (this.state.name === '__from') { + return dateTimeFormat(timeRange.state.value.from, { timeZone: timeRange.getTimeZone() }); + } else { + return dateTimeFormat(timeRange.state.value.to, { timeZone: timeRange.getTimeZone() }); + } + } +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 662d92a32df..48581e303ae 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -88,6 +88,7 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { addNewRowTo } from './layouts-shared/addNew'; import { clearClipboard } from './layouts-shared/paste'; +import { getIsLazy } from './layouts-shared/utils'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { LayoutParent } from './types/LayoutParent'; @@ -198,7 +199,7 @@ export class DashboardScene extends SceneObjectBase impleme meta: {}, editable: true, $timeRange: state.$timeRange ?? new SceneTimeRange({}), - body: state.body ?? DefaultGridLayoutManager.fromVizPanels(), + body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)), links: state.links ?? [], ...state, editPane: new DashboardEditPane(), diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 3d9acceed0b..e19a7331d5c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -208,8 +208,11 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { if (panel) { this._viewEventSub?.unsubscribe(); this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) }); + this._viewEventSub = undefined; } }); + + this._scene.state.body.activateRepeaters?.(); } } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx index be19f886d83..b96550cfa22 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayout.tsx @@ -22,9 +22,6 @@ export interface AutoGridLayoutState extends SceneObjectState, AutoGridLayoutOpt */ md?: AutoGridLayoutOptions; - /** True when the items should be lazy loaded */ - isLazy?: boolean; - /** True when the items should be draggable */ isDraggable?: boolean; diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx index 7f66994354d..2a44564b536 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx @@ -1,4 +1,5 @@ import { css, cx } from '@emotion/css'; +import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { LazyLoader, SceneComponentProps, sceneGraph } from '@grafana/scenes'; @@ -8,18 +9,21 @@ import { useHasClonedParents } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; +import { getIsLazy } from '../layouts-shared/utils'; import { AutoGridLayout, AutoGridLayoutState } from './AutoGridLayout'; import { AutoGridLayoutManager } from './AutoGridLayoutManager'; export function AutoGridLayoutRenderer({ model }: SceneComponentProps) { - const { children, isHidden, isLazy } = model.useState(); + const { children, isHidden } = model.useState(); const hasClonedParents = useHasClonedParents(model); const styles = useStyles2(getStyles, model.state); - const { layoutOrchestrator, isEditing } = useDashboardState(model); + const { layoutOrchestrator, isEditing, preload } = useDashboardState(model); const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager); const { fillScreen } = layoutManager.useState(); + const isLazy = useMemo(() => getIsLazy(preload), [preload]); + if (isHidden || !layoutOrchestrator) { return null; } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index b85cb2bcf4b..76fffddd0a5 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -46,6 +46,7 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; +import { getIsLazy } from '../layouts-shared/utils'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -558,10 +559,11 @@ export class DefaultGridLayoutManager public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager { const panels = currentLayout.getVizPanels(); - return DefaultGridLayoutManager.fromVizPanels(panels); + const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!; + return DefaultGridLayoutManager.fromVizPanels(panels, isLazy); } - public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager { + public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager { const children: DashboardGridItem[] = []; const panelHeight = 10; const panelWidth = GRID_COLUMN_COUNT / 3; @@ -599,6 +601,7 @@ export class DefaultGridLayoutManager children: children, isDraggable: true, isResizable: true, + isLazy, }), }); } @@ -606,7 +609,8 @@ export class DefaultGridLayoutManager public static fromGridItems( gridItems: SceneGridItemLike[], isDraggable?: boolean, - isResizable?: boolean + isResizable?: boolean, + isLazy?: boolean | undefined ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); @@ -620,6 +624,7 @@ export class DefaultGridLayoutManager children, isDraggable, isResizable, + isLazy, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index b03b8b26e26..affa68d54ea 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -83,6 +83,7 @@ export function RowItemRenderer({ model }: SceneComponentProps) { setTimeout(() => onSelect?.(evt)); }} + data-testid={selectors.components.DashboardRow.wrapper(title!)} {...dragProvided.draggableProps} > {(!isHeaderHidden || isEditing) && ( diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 34b2a9e3a1a..1b04a9d6c23 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -258,7 +258,8 @@ export class RowsLayoutManager extends SceneObjectBase i layout: DefaultGridLayoutManager.fromGridItems( rowConfig.children, rowConfig.isDraggable ?? layout.state.grid.state.isDraggable, - rowConfig.isResizable ?? layout.state.grid.state.isResizable + rowConfig.isResizable ?? layout.state.grid.state.isResizable, + layout.state.grid.state.isLazy ), }) ); diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts b/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts index 41402564081..eac9ff86333 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { SceneObject } from '@grafana/scenes'; +import { contextSrv } from 'app/core/core'; import { DashboardLayoutManager, isDashboardLayoutManager } from '../types/DashboardLayoutManager'; import { isLayoutParent } from '../types/LayoutParent'; @@ -75,3 +76,8 @@ export function ungroupLayout(layout: DashboardLayoutManager, innerLayout: Dashb layoutParent.switchLayout(innerLayout); } } + +export function getIsLazy(preload: boolean | undefined): boolean { + // We don't want to lazy load panels in the case of image renderer + return !(preload || (contextSrv.user && contextSrv.user.authenticatedBy === 'render')); +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts index 8688d1cbb08..e19059547b7 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/DefaultGridLayoutSerializer.ts @@ -9,10 +9,10 @@ import { PanelKind, LibraryPanelKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; -import { contextSrv } from 'app/core/core'; import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; +import { getIsLazy } from '../../scene/layouts-shared/utils'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { calculateGridItemDimensions, isLibraryPanel } from '../../utils/utils'; @@ -41,7 +41,7 @@ export function deserializeDefaultGridLayout( } return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ - isLazy: !(preload || contextSrv.user.authenticatedBy === 'render'), + isLazy: getIsLazy(preload), children: createSceneGridLayoutForItems(layout, elements, panelIdGenerator), }), }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 986e0018329..2b6dc7d5864 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -51,6 +51,10 @@ import { DeprecatedInternalId, } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { + getDashboardInteractionCallback, + getDashboardSceneProfiler, +} from 'app/features/dashboard/services/DashboardProfiler'; import { DashboardMeta } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; @@ -157,6 +161,15 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo uid === '*' || uid === metadata.name) !== -1, + onProfileComplete: getDashboardInteractionCallback(metadata.name, dashboard.title), + }, + getDashboardSceneProfiler() + ); + const dashboardScene = new DashboardScene( { description: dashboard.description, @@ -184,7 +197,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo uid === '*' || uid === oldModel.uid) !== -1, + onProfileComplete: getDashboardInteractionCallback(oldModel.uid, oldModel.title), + }, + getDashboardSceneProfiler() + ); + const behaviorList: SceneObjectState['$behaviors'] = [ new behaviors.CursorSync({ sync: oldModel.graphTooltip, }), - new behaviors.SceneQueryController({ - enableProfiling: - config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1, - onProfileComplete: getDashboardInteractionCallback(oldModel.uid, oldModel.title), - }), + queryController, registerDashboardMacro, registerPanelInteractionsReporter, new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }), @@ -318,7 +326,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel, } else { body = new DefaultGridLayoutManager({ grid: new SceneGridLayout({ - isLazy: !(dto.preload || contextSrv.user.authenticatedBy === 'render'), + isLazy: getIsLazy(dto.preload), children: createSceneObjectsForPanels(oldModel.panels), }), }); @@ -499,40 +507,3 @@ export const convertOldSnapshotToScenesSnapshot = (panel: PanelModel) => { panel.snapshotData = []; } }; - -function getDashboardInteractionCallback(uid: string, title: string) { - return (e: SceneInteractionProfileEvent) => { - let interactionType = ''; - - if (e.origin === 'SceneTimeRange') { - interactionType = 'time-range-change'; - } else if (e.origin === 'SceneRefreshPicker') { - interactionType = 'refresh'; - } else if (e.origin === 'DashboardScene') { - interactionType = 'view'; - } else if (e.origin.indexOf('Variable') > -1) { - interactionType = 'variable-change'; - } - reportInteraction('dashboard-render', { - interactionType, - duration: e.duration, - networkDuration: e.networkDuration, - totalJSHeapSize: e.totalJSHeapSize, - usedJSHeapSize: e.usedJSHeapSize, - jsHeapSizeLimit: e.jsHeapSizeLimit, - }); - - logMeasurement( - `dashboard.${interactionType}`, - { - duration: e.duration, - networkDuration: e.networkDuration, - totalJSHeapSize: e.totalJSHeapSize, - usedJSHeapSize: e.usedJSHeapSize, - jsHeapSizeLimit: e.jsHeapSizeLimit, - timeSinceBoot: performance.measure('time_since_boot', 'frontend_boot_js_done_time_seconds').duration, - }, - { dashboard: uid, title: title } - ); - }; -} diff --git a/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx index 53bb342cd21..9d101b2f8c5 100644 --- a/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.tsx @@ -11,7 +11,7 @@ import { PROVISIONING_URL } from 'app/features/provisioning/constants'; import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields'; import { ProvisionedDashboardFormData } from '../saving/shared'; import { DashboardScene } from '../scene/DashboardScene'; -import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler'; +import { useProvisionedRequestHandler, ProvisionedOperationInfo } from '../utils/useProvisionedRequestHandler'; import { buildResourceBranchRedirectUrl } from './utils'; @@ -67,7 +67,7 @@ export function DeleteProvisionedDashboardForm({ const navigate = useNavigate(); - const onRequestError = (error: unknown) => { + const onError = (error: unknown) => { getAppEvents().publish({ type: AppEvents.alertError.name, payload: [t('dashboard-scene.delete-provisioned-dashboard-form.api-error', 'Failed to delete dashboard'), error], @@ -75,32 +75,36 @@ export function DeleteProvisionedDashboardForm({ }; const onWriteSuccess = () => { + dashboard.setState({ isDirty: false }); panelEditor?.onDiscard(); - onDismiss(); // TODO reset search state instead window.location.href = '/dashboards'; }; - const onBranchSuccess = (path: string, urls?: Record) => { + const onBranchSuccess = (path: string, info: ProvisionedOperationInfo, urls?: Record) => { panelEditor?.onDiscard(); - onDismiss(); const url = buildResourceBranchRedirectUrl({ baseUrl: `${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}`, paramName: 'pull_request_url', paramValue: urls?.newPullRequestURL, - repoType: request.data?.repository?.type, + repoType: info.repoType, }); navigate(url); }; useProvisionedRequestHandler({ - dashboard, request, workflow, + resourceType: 'dashboard', + successMessage: t( + 'dashboard-scene.delete-provisioned-dashboard-form.success-message', + 'Dashboard deleted successfully' + ), handlers: { - onBranchSuccess: ({ path, urls }) => onBranchSuccess(path, urls), + onDismiss, + onBranchSuccess: ({ path, urls }, info) => onBranchSuccess(path, info, urls), onWriteSuccess, - onError: onRequestError, + onError, }, }); diff --git a/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx index 8d431f3f6a1..2ad4f936309 100644 --- a/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx @@ -18,7 +18,7 @@ import { getTargetFolderPathInRepo } from 'app/features/browse-dashboards/compon import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields'; import { ProvisionedDashboardFormData } from '../saving/shared'; import { DashboardScene } from '../scene/DashboardScene'; -import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler'; +import { useProvisionedRequestHandler, ProvisionedOperationInfo } from '../utils/useProvisionedRequestHandler'; import { buildResourceBranchRedirectUrl } from './utils'; @@ -118,6 +118,7 @@ export function MoveProvisionedDashboardForm({ }; const onWriteSuccess = () => { + dashboard.setState({ isDirty: false }); panelEditor?.onDiscard(); if (targetFolderUID && targetFolderTitle) { onSuccess(targetFolderUID, targetFolderTitle); @@ -125,23 +126,40 @@ export function MoveProvisionedDashboardForm({ navigate('/dashboards'); }; - const onBranchSuccess = () => { + const onBranchSuccess = (info: ProvisionedOperationInfo) => { + dashboard.setState({ isDirty: false }); panelEditor?.onDiscard(); const url = buildResourceBranchRedirectUrl({ paramName: 'new_pull_request_url', paramValue: moveRequest?.data?.urls?.newPullRequestURL, - repoType: moveRequest?.data?.repository?.type, + repoType: info.repoType, }); navigate(url); }; + const onError = (error: unknown) => { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t('dashboard-scene.move-provisioned-dashboard-form.alert-error-moving-dashboard', 'Error moving dashboard'), + error, + ], + }); + }; + useProvisionedRequestHandler({ - dashboard, request: moveRequest, workflow, + successMessage: t( + 'dashboard-scene.move-provisioned-dashboard-form.success-message', + 'Dashboard moved successfully' + ), + resourceType: 'dashboard', handlers: { - onBranchSuccess, + onBranchSuccess: (_, info) => onBranchSuccess(info), onWriteSuccess, + onDismiss, + onError, }, }); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx index d5524108b07..c0099c17f2f 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx @@ -52,6 +52,7 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene }) menuItems.push({ shareId: shareDashboardType.image, + testId: newExportButtonSelector.exportAsImage, icon: 'camera', label: t('share-dashboard.menu.export-image-title', 'Export as image'), renderCondition: Boolean(config.featureToggles.sharingDashboardImage), diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts b/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts index 1c4ad371d0a..fa13471055a 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts +++ b/public/app/features/dashboard-scene/sharing/ExportButton/utils.test.ts @@ -96,6 +96,13 @@ describe('Dashboard Export Image Utils', () => { const fetchMock = jest.fn().mockReturnValue(of({ ok: true, data: mockBlob })); (getBackendSrv as jest.Mock).mockReturnValue({ fetch: fetchMock }); + // Mock window.innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1280, + }); + const dashboard = { state: { uid: 'test-uid', @@ -119,7 +126,7 @@ describe('Dashboard Export Image Utils', () => { absolute: true, updateQuery: { height: -1, - width: 1000, + width: 1280, scale: 2, kiosk: true, hideNav: true, @@ -128,5 +135,46 @@ describe('Dashboard Export Image Utils', () => { }, }); }); + + it('should fallback to config width when window.innerWidth is not available', async () => { + config.rendererAvailable = true; + config.rendererDefaultImageWidth = 1500; + const mockBlob = new Blob(['test'], { type: 'image/png' }); + const fetchMock = jest.fn().mockReturnValue(of({ ok: true, data: mockBlob })); + (getBackendSrv as jest.Mock).mockReturnValue({ fetch: fetchMock }); + + // Ensure window.innerWidth is undefined + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: undefined, + }); + + const dashboard = { + state: { + uid: 'test-uid', + }, + } as DashboardScene; + + const result = await generateDashboardImage({ dashboard, scale: 1 }); + + expect(result.error).toBeUndefined(); + expect(result.blob).toBe(mockBlob); + expect(getDashboardUrl).toHaveBeenCalledWith({ + uid: 'test-uid', + currentQueryParams: '', + render: true, + absolute: true, + updateQuery: { + height: -1, + width: 1500, // Should use config value + scale: 1, + kiosk: true, + hideNav: true, + orgId: '1', + fullPageImage: true, + }, + }); + }); }); }); diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts b/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts index 64d84893b45..f9fb9e5f04b 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts +++ b/public/app/features/dashboard-scene/sharing/ExportButton/utils.ts @@ -28,7 +28,7 @@ export interface ImageGenerationResult { */ export async function generateDashboardImage({ dashboard, - scale = config.rendererDefaultImageScale || 1, + scale = config.rendererDefaultImageScale || 2, }: ImageGenerationOptions): Promise { try { // Check if renderer plugin is available @@ -46,7 +46,7 @@ export async function generateDashboardImage({ absolute: true, updateQuery: { height: -1, // image renderer will scroll through the dashboard and set the appropriate height - width: config.rendererDefaultImageWidth || 1000, + width: window.innerWidth || config.rendererDefaultImageWidth || 1000, scale, kiosk: true, hideNav: true, diff --git a/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.test.ts b/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.test.ts index 0587691ace0..d8c4f8a0a96 100644 --- a/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.test.ts +++ b/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.test.ts @@ -3,18 +3,10 @@ import { renderHook } from '@testing-library/react'; import { AppEvents } from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { - DeleteRepositoryFilesWithPathApiResponse, - GetRepositoryFilesWithPathApiResponse, - ResourceWrapper, -} from 'app/api/clients/provisioning/v0alpha1'; -import { Resource } from 'app/features/apiserver/types'; +import { ResourceWrapper } from 'app/api/clients/provisioning/v0alpha1'; -import { DashboardScene } from '../scene/DashboardScene'; +import { useProvisionedRequestHandler, RequestHandlers } from './useProvisionedRequestHandler'; -import { useProvisionedRequestHandler } from './useProvisionedRequestHandler'; - -// Mock dependencies jest.mock('@grafana/runtime', () => ({ getAppEvents: jest.fn(), })); @@ -30,9 +22,9 @@ describe('useProvisionedRequestHandler', () => { jest.clearAllMocks(); }); - describe('when request has an error', () => { - it('should call onError handler', () => { - const { request, handlers, dashboard } = setup({ + describe('error handling', () => { + it('should call onError handler with correct parameters', () => { + const { request, handlers } = setup({ requestOverrides: { isError: true, isSuccess: false, @@ -42,264 +34,127 @@ describe('useProvisionedRequestHandler', () => { renderHook(() => useProvisionedRequestHandler({ - dashboard, request, + repository: { + type: 'github', + name: 'test-repo', + target: 'folder', + title: 'Test Repository', + workflows: [], + }, + resourceType: 'dashboard', handlers, }) ); - expect(handlers.onError).toHaveBeenCalledWith(new Error('Test error')); + expect(handlers.onError).toHaveBeenCalledWith( + new Error('Test error'), + expect.objectContaining({ + resourceType: 'dashboard', + repoType: 'github', + }) + ); expect(handlers.onBranchSuccess).not.toHaveBeenCalled(); expect(handlers.onWriteSuccess).not.toHaveBeenCalled(); - expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled(); }); }); - describe('when request is successful', () => { - it('should set dashboard isDirty to false', () => { - const { request, handlers, dashboard } = setup({ + describe('success handling', () => { + it('should publish success event and call onDismiss', () => { + const { request, handlers, mockPublish } = setup({ requestOverrides: { isError: false, isSuccess: true, - data: { - ref: 'main', - path: '/path/to/dashboard', - }, - }, - workflowOverride: 'branch', - }); - - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - workflow: 'branch', - handlers, - }) - ); - - expect(dashboard.setState).toHaveBeenCalledWith({ isDirty: false }); - }); - - it('should publish success event', () => { - const { request, handlers, dashboard, mockPublish } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: {}, + data: createMockResourceWrapper(), }, }); renderHook(() => useProvisionedRequestHandler({ - dashboard, request, + resourceType: 'dashboard', handlers, }) ); expect(mockPublish).toHaveBeenCalledWith({ type: AppEvents.alertSuccess.name, - payload: ['Dashboard changes saved successfully'], + payload: ['Dashboard saved successfully'], }); + expect(handlers.onDismiss).toHaveBeenCalled(); }); - describe('branch workflow', () => { - it('should call onBranchSuccess when workflow is branch and data has ref and path', () => { - const { request, handlers, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: { - ref: 'feature-branch', - path: '/path/to/dashboard.json', - urls: { compareURL: 'http://example.com/edit' }, - }, - }, - workflowOverride: 'branch', - }); + it('should call onBranchSuccess for branch workflow', () => { + const { request, handlers } = setup({ + requestOverrides: { + isError: false, + isSuccess: true, + data: createMockResourceWrapper({ + ref: 'feature-branch', + path: '/path/to/dashboard.json', + urls: { compareURL: 'http://example.com/edit' }, + }), + }, + }); - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - workflow: 'branch', - handlers, - }) - ); + renderHook(() => + useProvisionedRequestHandler({ + request, + workflow: 'branch', + resourceType: 'dashboard', + handlers, + }) + ); - expect(handlers.onBranchSuccess).toHaveBeenCalledWith({ + expect(handlers.onBranchSuccess).toHaveBeenCalledWith( + { ref: 'feature-branch', path: '/path/to/dashboard.json', urls: { compareURL: 'http://example.com/edit' }, - }); - expect(handlers.onWriteSuccess).not.toHaveBeenCalled(); - }); - - it('should not call onBranchSuccess when ref is missing', () => { - const { request, handlers, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: { - path: '/path/to/dashboard.json', - }, - }, - workflowOverride: 'branch', - }); - - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - workflow: 'branch', - handlers, - }) - ); - - expect(handlers.onBranchSuccess).not.toHaveBeenCalled(); - expect(handlers.onWriteSuccess).toHaveBeenCalled(); - }); + }, + expect.objectContaining({ + resourceType: 'dashboard', + repoType: 'git', + workflow: 'branch', + }), + expect.any(Object) + ); + expect(handlers.onWriteSuccess).not.toHaveBeenCalled(); }); - describe('new dashboard flow', () => { - it('should call onNewDashboardSuccess when isNew is true and resource.upsert exists', () => { - const mockUpsertResource = { - metadata: { - name: 'test-dashboard', - uid: 'test-uid', - resourceVersion: '1', - creationTimestamp: new Date().toISOString(), - }, - spec: { title: 'Test Dashboard' } as Dashboard, - apiVersion: 'v1', - kind: 'Dashboard', - }; - - const mockResource = { - metadata: { - name: 'test-dashboard', - uid: 'test-uid', - resourceVersion: '1', - creationTimestamp: new Date().toISOString(), - }, - spec: { title: 'Test Dashboard' } as Dashboard, - apiVersion: 'v1', - kind: 'Dashboard', - upsert: mockUpsertResource, - } as Resource & { upsert: Resource }; - - const { request, handlers, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: { - repository: 'test-repo', - resource: mockResource, - } as unknown as ProvisionedRequestData, - }, - }); - - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - handlers, - isNew: true, - }) - ); - - expect(handlers.onNewDashboardSuccess).toHaveBeenCalledWith(mockResource.upsert); - expect(handlers.onWriteSuccess).not.toHaveBeenCalled(); + it('should call onWriteSuccess for write workflow', () => { + const { request, handlers } = setup({ + requestOverrides: { + isError: false, + isSuccess: true, + data: createMockResourceWrapper(), + }, }); - it('should not call onNewDashboardSuccess when isNew is false', () => { - const { request, handlers, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: { - repository: 'test-repo', - resource: { - upsert: { - apiVersion: 'v1', - kind: 'Dashboard', - metadata: { name: 'test-dashboard' }, - spec: { title: 'Test Dashboard' } as Dashboard, - }, - metadata: { name: 'test-dashboard' }, - spec: { title: 'Test Dashboard' } as Dashboard, - apiVersion: 'v1', - kind: 'Dashboard', - } as unknown as Resource, - } as unknown as ProvisionedRequestData, - }, - }); + renderHook(() => + useProvisionedRequestHandler({ + request, + workflow: 'write', + resourceType: 'dashboard', + handlers, + }) + ); - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - handlers, - isNew: false, - }) - ); - - expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled(); - expect(handlers.onWriteSuccess).toHaveBeenCalled(); - }); - - it('should not call onNewDashboardSuccess when resource.upsert is missing', () => { - const { request, handlers, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: { - resource: {}, - } as ResourceWrapper, - }, - }); - - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - handlers, - isNew: true, - }) - ); - - expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled(); - expect(handlers.onWriteSuccess).toHaveBeenCalled(); - }); - }); - - describe('write workflow', () => { - it('should call onWriteSuccess as fallback', () => { - const { request, handlers, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - data: {} as GetRepositoryFilesWithPathApiResponse, - }, - }); - - renderHook(() => - useProvisionedRequestHandler({ - dashboard, - request, - handlers, - }) - ); - - expect(handlers.onWriteSuccess).toHaveBeenCalled(); - }); + expect(handlers.onWriteSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'dashboard', + repoType: 'git', + workflow: 'write', + }), + expect.any(Object) + ); + expect(handlers.onDismiss).toHaveBeenCalled(); }); }); - describe('when request is neither error nor success', () => { - it('should not call any handlers', () => { - const { request, handlers, dashboard, mockPublish } = setup({ + describe('edge cases', () => { + it('should not call any handlers when request is loading', () => { + const { request, handlers, mockPublish } = setup({ requestOverrides: { isError: false, isSuccess: false, @@ -309,7 +164,6 @@ describe('useProvisionedRequestHandler', () => { renderHook(() => useProvisionedRequestHandler({ - dashboard, request, handlers, }) @@ -318,15 +172,11 @@ describe('useProvisionedRequestHandler', () => { expect(handlers.onError).not.toHaveBeenCalled(); expect(handlers.onBranchSuccess).not.toHaveBeenCalled(); expect(handlers.onWriteSuccess).not.toHaveBeenCalled(); - expect(handlers.onNewDashboardSuccess).not.toHaveBeenCalled(); - expect(dashboard.setState).not.toHaveBeenCalled(); expect(mockPublish).not.toHaveBeenCalled(); }); - }); - describe('when request success but no data', () => { - it('should not call any handlers when data is undefined', () => { - const { request, handlers, dashboard, mockPublish } = setup({ + it('should not call handlers when success but no data', () => { + const { request, handlers, mockPublish } = setup({ requestOverrides: { isError: false, isSuccess: true, @@ -336,7 +186,6 @@ describe('useProvisionedRequestHandler', () => { renderHook(() => useProvisionedRequestHandler({ - dashboard, request, handlers, }) @@ -344,25 +193,18 @@ describe('useProvisionedRequestHandler', () => { expect(handlers.onWriteSuccess).not.toHaveBeenCalled(); expect(handlers.onBranchSuccess).not.toHaveBeenCalled(); - expect(dashboard.setState).not.toHaveBeenCalled(); expect(mockPublish).not.toHaveBeenCalled(); }); - }); - describe('optional handlers', () => { it('should not throw when optional handlers are not provided', () => { - const { request, dashboard } = setup({ - requestOverrides: { - isError: false, - isSuccess: true, - }, + const { request } = setup({ + requestOverrides: { isError: false, isSuccess: true }, handlersOverrides: {}, }); expect(() => { renderHook(() => useProvisionedRequestHandler({ - dashboard, request, handlers: {}, }) @@ -372,62 +214,69 @@ describe('useProvisionedRequestHandler', () => { }); }); -type ProvisionedRequestData = DeleteRepositoryFilesWithPathApiResponse | GetRepositoryFilesWithPathApiResponse; +// Helper function to create a properly structured mock ResourceWrapper +function createMockResourceWrapper(overrides: Partial = {}): ResourceWrapper { + return { + repository: { + name: 'test-repo', + namespace: 'default', + title: 'Test Repository', + type: 'git', + }, + resource: { + type: { + kind: 'Dashboard', + }, + upsert: { + apiVersion: 'v1', + kind: 'Dashboard', + metadata: { name: 'test-dashboard', uid: 'test-uid' }, + spec: { title: 'Test Dashboard' }, + }, + }, + ...overrides, + }; +} function setup({ requestOverrides = {}, handlersOverrides = {}, - workflowOverride, }: { requestOverrides?: Partial<{ isError: boolean; isSuccess: boolean; isLoading?: boolean; error?: unknown; - data?: Partial; + data?: ResourceWrapper; }>; - handlersOverrides?: Partial<{ - onBranchSuccess?: jest.Mock; - onWriteSuccess?: jest.Mock; - onNewDashboardSuccess?: jest.Mock; - onError?: jest.Mock; - }>; - workflowOverride?: string; + handlersOverrides?: Partial>; } = {}) { const mockPublish = jest.fn(); - const mockSetState = jest.fn(); mockGetAppEvents.mockReturnValue({ publish: mockPublish, } as unknown as ReturnType); - const dashboard = { - setState: mockSetState, - } as unknown as DashboardScene; - const request = { isError: false, isSuccess: false, isLoading: false, error: undefined, data: undefined, - ...(requestOverrides as ResourceWrapper), + ...requestOverrides, }; - const handlers = { + const handlers: RequestHandlers = { onError: jest.fn(), onBranchSuccess: jest.fn(), onWriteSuccess: jest.fn(), - onNewDashboardSuccess: jest.fn(), + onDismiss: jest.fn(), ...handlersOverrides, }; return { - dashboard, request, handlers, mockPublish, - mockSetState, - workflow: workflowOverride, }; } diff --git a/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.ts b/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.ts index d5b9ca4ba7b..45fb3f18b79 100644 --- a/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.ts +++ b/public/app/features/dashboard-scene/utils/useProvisionedRequestHandler.ts @@ -3,20 +3,32 @@ import { useEffect } from 'react'; import { AppEvents } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; -import { Dashboard } from '@grafana/schema'; import { DeleteRepositoryFilesWithPathApiResponse, GetRepositoryFilesWithPathApiResponse, + RepositoryView, } from 'app/api/clients/provisioning/v0alpha1'; import { Resource } from 'app/features/apiserver/types'; +import { RepoType } from 'app/features/provisioning/Wizard/types'; -import { DashboardScene } from '../scene/DashboardScene'; +type ResourceType = 'dashboard' | 'folder'; // Add more as needed, e.g., 'alert', etc. -interface RequestHandlers { - onBranchSuccess?: (data: { ref: string; path: string; urls?: Record }) => void; - onWriteSuccess?: () => void; - onNewDashboardSuccess?: (resource: Resource) => void; - onError?: (error: unknown) => void; +// Information object that gets passed to all handlers +interface ProvisionedOperationInfo { + repoType: RepoType; + resourceType?: ResourceType; + workflow?: string; +} + +interface RequestHandlers { + onBranchSuccess?: ( + data: { ref: string; path: string; urls?: Record }, + info: ProvisionedOperationInfo, + resource: Resource + ) => void; + onWriteSuccess?: (info: ProvisionedOperationInfo, resource: Resource) => void; + onError?: (error: unknown, info: ProvisionedOperationInfo) => void; + onDismiss?: () => void; } interface ProvisionedRequest { @@ -27,51 +39,85 @@ interface ProvisionedRequest { data?: DeleteRepositoryFilesWithPathApiResponse | GetRepositoryFilesWithPathApiResponse; } -// This hook handles save new dashboard, edit existing dashboard, and delete dashboard response logic for provisioned dashboards. -export function useProvisionedRequestHandler({ - dashboard, +// Resource-specific configuration for different resource types +interface ResourceConfig { + defaultSuccessMessage: string; + supportedWorkflows: string[]; +} + +/** + * Generic hook for handling provisioned resource operations across any resource type and repository provider. + * + * This hook is intentionally decoupled from specific components (like DashboardScene) to promote reusability. + * Components are responsible for their own state management through specific workflow handlers. + */ +export function useProvisionedRequestHandler({ request, workflow, handlers, - isNew, + successMessage, + repository, + resourceType, }: { - dashboard: DashboardScene; request: ProvisionedRequest; workflow?: string; - handlers: RequestHandlers; - isNew?: boolean; + handlers: RequestHandlers; + successMessage?: string; + repository?: RepositoryView; + resourceType?: ResourceType; }) { useEffect(() => { + const repoType = repository?.type || 'git'; + const info: ProvisionedOperationInfo = { + repoType, + resourceType, + workflow, + }; + if (request.isError) { - handlers.onError?.(request.error); + handlers.onError?.(request.error, info); return; } if (request.isSuccess && request.data) { - dashboard.setState({ isDirty: false }); const { ref, path, urls, resource } = request.data; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const resourceData = resource.upsert as Resource; - // Branch workflow - if (workflow === 'branch' && ref && path) { - handlers.onBranchSuccess?.({ ref, path, urls }); - return; - } - - // Success message (could be configurable) + // Success message + const message = successMessage || getContextualSuccessMessage(info); getAppEvents().publish({ type: AppEvents.alertSuccess.name, - payload: [t('dashboard-scene.edit-provisioned-dashboard-form.success', 'Dashboard changes saved successfully')], + payload: [message], }); - // New dashboard flow - if (isNew && resource?.upsert && handlers.onNewDashboardSuccess) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handlers.onNewDashboardSuccess(resource.upsert as Resource); - return; + // Branch workflow + if (workflow === 'branch' && handlers.onBranchSuccess && ref && path) { + const branchData = { ref, path, urls }; + handlers.onBranchSuccess?.(branchData, info, resourceData); } // Write workflow - handlers.onWriteSuccess?.(); + if (workflow === 'write' && handlers.onWriteSuccess) { + handlers.onWriteSuccess(info, resourceData); + } + + handlers.onDismiss?.(); } - }, [request, workflow, handlers, isNew, dashboard]); + }, [request, workflow, handlers, successMessage, repository, resourceType]); } + +function getContextualSuccessMessage(info: ProvisionedOperationInfo): string { + const { resourceType } = info; + + switch (resourceType) { + case 'dashboard': + return t('provisioned-resource-request-handler-dashboard', 'Dashboard saved successfully'); + case 'folder': + return t('provisioned-resource-request-handler-folder', 'Folder created successfully'); + default: + return t('provisioned-resource-request-handler', 'Resource saved successfully'); + } +} + +export type { ResourceType, ProvisionedOperationInfo, RequestHandlers, ResourceConfig }; diff --git a/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts b/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts index a03cc64bb5a..412d37d4bfc 100644 --- a/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts +++ b/public/app/features/dashboard/dashgrid/SeriesVisibilityConfigFactory.ts @@ -97,7 +97,7 @@ function createOverride( value: { viz: true, legend: false, - tooltip: false, + tooltip: true, }, }; @@ -118,7 +118,7 @@ function createOverride( value: { viz: true, legend: false, - tooltip: false, + tooltip: true, }, }, ], diff --git a/public/app/features/dashboard/services/DashboardProfiler.ts b/public/app/features/dashboard/services/DashboardProfiler.ts new file mode 100644 index 00000000000..aa4ce4d2caa --- /dev/null +++ b/public/app/features/dashboard/services/DashboardProfiler.ts @@ -0,0 +1,34 @@ +import { logMeasurement, reportInteraction } from '@grafana/runtime'; +import { SceneInteractionProfileEvent, SceneRenderProfiler } from '@grafana/scenes'; + +let dashboardSceneProfiler: SceneRenderProfiler | undefined; + +export function getDashboardSceneProfiler() { + if (!dashboardSceneProfiler) { + dashboardSceneProfiler = new SceneRenderProfiler(); + } + return dashboardSceneProfiler; +} + +export function getDashboardInteractionCallback(uid: string, title: string) { + return (e: SceneInteractionProfileEvent) => { + const payload = { + duration: e.duration, + networkDuration: e.networkDuration, + startTs: e.startTs, + endTs: e.endTs, + totalJSHeapSize: e.totalJSHeapSize, + usedJSHeapSize: e.usedJSHeapSize, + jsHeapSizeLimit: e.jsHeapSizeLimit, + timeSinceBoot: performance.measure('time_since_boot', 'frontend_boot_js_done_time_seconds').duration, + }; + + reportInteraction('dashboard_render', { + interactionType: e.origin, + uid, + ...payload, + }); + + logMeasurement(`dashboard_render`, payload, { interactionType: e.origin, dashboard: uid, title: title }); + }; +} diff --git a/public/app/features/dashboard/services/dashboard-render-performance-profiling.md b/public/app/features/dashboard/services/dashboard-render-performance-profiling.md new file mode 100644 index 00000000000..574e4f7eb90 --- /dev/null +++ b/public/app/features/dashboard/services/dashboard-render-performance-profiling.md @@ -0,0 +1,199 @@ +# Grafana Dashboard Render Performance Metrics + +This documentation describes the dashboard render performance metrics exposed from Grafana's frontend. + +## Overview + +The exposed dashboard performance metrics feature provides comprehensive tracking and profiling of dashboard interactions, allowing administrators and developers to analyze dashboard render performance, user interactions, and identify performance bottlenecks. + +## Configuration + +### Enabling Performance Metrics + +Dashboard performance metrics are configured in the Grafana configuration file (`grafana.ini`) under the `[dashboards]` section: + +```ini +[dashboards] +# Dashboards UIDs to report performance metrics for. * can be used to report metrics for all dashboards +dashboard_performance_metrics = * +``` + +**Configuration Options:** + +- **`*`** - Enable profiling on all dashboards +- **``** - Enable profiling on specific dashboards only +- **`""` (empty)** - Disable performance metrics (default) + +**Examples:** + +```ini +# Enable for all dashboards +dashboard_performance_metrics = * + +# Enable for specific dashboards +dashboard_performance_metrics = dashboard-uid-1,dashboard-uid-2,dashboard-uid-3 + +# Disable performance metrics +dashboard_performance_metrics = +``` + +## Tracked Interactions + +The system tracks various dashboard interaction types automatically using the [`@grafana/scenes`](https://github.com/grafana/scenes) library. Each interaction is captured with a specific origin identifier that describes the type of user action performed. In Grafana, these interaction events are then reported as `dashboard_render` events with interaction type information included. + +### Core Performance-Tracked Interactions + +The following dashboard interaction types are tracked for dashboard render performance profiling: + +| Interaction Type | Trigger | When Measured | +| ------------------------ | -------------------------- | -------------------------------------------------------- | +| `dashboard_view` | Dashboard view | When user loads or navigates to a dashboard | +| `refresh` | Manual/Auto refresh | When user clicks refresh button or auto-refresh triggers | +| `time_range_change` | Time picker changes | When user changes time range in time picker | +| `filter_added` | Ad-hoc filter addition | When user adds a new filter to the dashboard | +| `filter_removed` | Ad-hoc filter removal | When user removes a filter from the dashboard | +| `filter_changed` | Ad-hoc filter modification | When user changes filter values or operators | +| `filter_restored` | Ad-hoc filter restoration | When user restores a previously applied filter | +| `variable_value_changed` | Variable value changes | When user changes dashboard variable values | +| `scopes_changed` | Scopes modifications | When user modifies dashboard scopes | + +The interactions mentioned above are reported to Echo service as well as sent to [Faro](https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/) as `dashboard_render` measurements: + +```ts +const payload = { + duration: e.duration, + networkDuration: e.networkDuration, + startTs: e.startTs, + endTs: e.endTs, + totalJSHeapSize: e.totalJSHeapSize, + usedJSHeapSize: e.usedJSHeapSize, + jsHeapSizeLimit: e.jsHeapSizeLimit, + timeSinceBoot: performance.measure('time_since_boot', 'frontend_boot_js_done_time_seconds').duration, +}; + +reportInteraction('dashboard_render', { + interactionType: e.origin, + uid, + ...payload, +}); + +logMeasurement(`dashboard_render`, payload, { interactionType: e.origin, dashboard: uid, title: title }); +``` + +### Interaction Origin Mapping + +The profiling system uses profiler event's `origin` directly as the `interactionType`, providing direct mapping between user actions and performance measurements. + +## Profiling Implementation + +### Profile Data Structure + +Each interaction profile event captures: + +```typescript +interface SceneInteractionProfileEvent { + origin: string; // Interaction type + duration: number; // Total interaction duration + networkDuration: number; // Network requests duration + totalJSHeapSize: number; // JavaScript heap size metrics + usedJSHeapSize: number; // Used JavaScript heap size + jsHeapSizeLimit: number; // JavaScript heap size limit + startTs: number; // Profile start timestamp + endTs: number; // Profile end timestamp +} +``` + +### Collected Metrics + +For each tracked interaction, the system collects: + +- **Dashboard Metadata**: UID, title +- **Performance Metrics**: Duration, network duration +- **Memory Metrics**: JavaScript heap usage statistics +- **Timing Information**: Time since boot, profile start and end timestamps +- **Interaction Context**: Type of user interaction + +## Debugging and Development + +### Enable Profiler Debug Logging + +To observe profiling events in the browser console: + +```javascript +localStorage.setItem('grafana.debug.scenes', 'true'); +``` + +#### Console Output + +When debug logging is enabled, you'll see console logs for each profiling event: + +``` +SceneRenderProfiler: Profile started: {origin: , crumbs: Array(0)} +... // intermediate steps adding profile crumbs +SceneRenderProfiler: Stopped recording, total measured time (network included): 2123 +``` + +### Enable Echo Service Debug Logging + +To observe Echo events in the browser console: + +```javascript +_debug.echo.enable(); +``` + +#### Console Output + +When Echo debug logging is enabled, you'll see console logs for each profiling event captured by Echo service: + +``` +[EchoSrv: interaction event]: {interactionName: 'dashboard_render', properties: {…}, meta: {…}} +``` + +### Browser Performance Profiler + +Dashboard interactions can be recorded in the browser's performance profiler, where they appear as: + +``` +Dashboard Interaction +``` + +## Analytics Integration + +### Interaction Reporting + +Performance data is integrated with Grafana's analytics system through: + +- **`reportInteraction`**: Reports interaction events to Echo service with performance data +- **`logMeasurement`**: Records Faro's performance measurements with metadata + +### Data Collection + +The system reports the following data for each interaction: + +```typescript +{ + interactionType: string, // Type of interaction + uid: string, // Dashboard UID + duration: number, // Total duration + networkDuration: number, // Network time + startTs: number, // Profile start timestamp + endTs: number, // Profile end timestamp + totalJSHeapSize: number, // Memory metrics + usedJSHeapSize: number, + jsHeapSizeLimit: number, + timeSinceBoot: number // Time since frontend boot +} +``` + +## Implementation Details + +The profiler is integrated into dashboard creation paths and uses a singleton pattern to share profiler instances across dashboard reloads. The performance tracking is implemented using the `SceneRenderProfiler` from the `@grafana/scenes` library. + +## Related Documentation + +- [PR #858 - Add SceneRenderProfiler to scenes](https://github.com/grafana/scenes/pull/858) +- [PR #99629 - Dashboard render performance metrics](https://github.com/grafana/grafana/pull/99629) +- [PR #108658 - Dashboard: Tweak interaction tracking](https://github.com/grafana/grafana/pull/108658) +- [PR #1195 - Enhance SceneRenderProfiler with additional interaction tracking](https://github.com/grafana/scenes/pull/1195) +- [PR #1198 - Make SceneRenderProfiler optional and injectable](https://github.com/grafana/scenes/pull/1198) +- [PR #1199 - SceneRenderProfiler: add start and end timestamps to profile events](https://github.com/grafana/scenes/pull/1199) diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index da9a51195f1..6b82290c993 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -927,6 +927,10 @@ export class DashboardMigrator { } } + if (oldVersion < 42) { + panelUpgrades.push(migrateHideFromFunctionality); + } + /** * -==- Add migration here -==- * Your migration should go below the previous @@ -1480,3 +1484,23 @@ function ensureXAxisVisibility(panel: PanelModel) { return panel; } + +function migrateHideFromFunctionality(panel: PanelModel) { + // migrate overrides with hideFrom.viz = true to also set tooltip = true + // this includes the __systemRef override + if (panel.fieldConfig && panel.fieldConfig.overrides) { + panel.fieldConfig.overrides = panel.fieldConfig.overrides.map((override) => { + if (override.properties) { + override.properties = override.properties.map((property) => { + if (property.id === 'custom.hideFrom' && property.value?.viz === true) { + property.value.tooltip = true; + } + return property; + }); + } + return override; + }); + } + + return panel; +} diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index a6d4040a446..fbac705c277 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -52,6 +52,7 @@ import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRow import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { LogRows } from 'app/features/logs/components/LogRows'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; +import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext'; import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList'; import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext'; import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel'; @@ -767,7 +768,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { return ( <> - {getRowContext && contextRow && ( + {(!config.featureToggles.newLogsPanel || !config.featureToggles.newLogContext) && getRowContext && contextRow && ( = (props: Props) => { timeZone={timeZone} /> )} + {config.featureToggles.newLogsPanel && config.featureToggles.newLogContext && getRowContext && contextRow && ( + getRowContext(row, contextRow, options)} + getRowContextQuery={getRowContextQuery} + getLogRowContextUi={getLogRowContextUi} + logOptionsStorageKey={SETTING_KEY_ROOT} + timeZone={timeZone} + displayedFields={displayedFields} + onClickShowField={showField} + onClickHideField={hideField} + /> + )} void; closeDrawer: () => void; isDrawerOpen: boolean; @@ -32,7 +37,7 @@ export type QueryLibraryContextType = { * Opens a modal for adding a query to the library. * @param query Query to be saved * @param options.onSave Callback that will be called after the query is saved. - * @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or + * @param options.context Used for rendering QueryEditor. Should identify the context this is called from, like 'explore' or * 'dashboard'. * @param options.title Default title for the modal, can be overridden by the query title. */ @@ -46,8 +51,9 @@ export type QueryLibraryContextType = { * Returns a predefined small button that can be used to save a query to the library. * @param query */ - renderSaveQueryButton: (query: DataQuery) => ReactNode; + renderSaveQueryButton: (query: DataQuery, app?: CoreApp) => ReactNode; queryLibraryEnabled: boolean; + context: string; }; export const QueryLibraryContext = createContext({ @@ -63,6 +69,7 @@ export const QueryLibraryContext = createContext({ }, queryLibraryEnabled: false, + context: 'unknown', }); export function useQueryLibraryContext() { diff --git a/public/app/features/explore/QueryLibrary/mocks.tsx b/public/app/features/explore/QueryLibrary/mocks.tsx index 9126fdabe42..e59732870a9 100644 --- a/public/app/features/explore/QueryLibrary/mocks.tsx +++ b/public/app/features/explore/QueryLibrary/mocks.tsx @@ -17,6 +17,7 @@ export function QueryLibraryContextProviderMock(props: PropsWithChildren) closeAddQueryModal: jest.fn(), renderSaveQueryButton: jest.fn(), queryLibraryEnabled: Boolean(props.queryLibraryEnabled), + context: 'explore', }} > {props.children} diff --git a/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx b/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx index 6c70ac1bf89..e99c0e6cebc 100644 --- a/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx @@ -22,7 +22,7 @@ export const RichHistoryAddToLibrary = ({ query }: Props) => { variant="secondary" aria-label={buttonLabel} onClick={() => { - openAddQueryModal(query, { onSave: () => setHasBeenSaved(true), context: 'richHistory' }); + openAddQueryModal(query, { onSave: () => setHasBeenSaved(true), context: 'rich-history' }); }} > {buttonLabel} diff --git a/public/app/features/explore/SecondaryActions.tsx b/public/app/features/explore/SecondaryActions.tsx index cd9d25b860b..b5b30aaae1d 100644 --- a/public/app/features/explore/SecondaryActions.tsx +++ b/public/app/features/explore/SecondaryActions.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data'; +import { CoreApp, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { ToolbarButton, useTheme2 } from '@grafana/ui'; @@ -76,7 +76,11 @@ export function SecondaryActions({ data-testid={selectors.pages.Explore.General.addFromQueryLibrary} aria-label={t('explore.secondary-actions.add-from-query-library', 'Add query from library')} variant="canvas" - onClick={() => openQueryLibraryDrawer(activeDatasources, onSelectQueryFromLibrary)} + onClick={() => + openQueryLibraryDrawer(activeDatasources, onSelectQueryFromLibrary, { + context: CoreApp.Explore, + }) + } icon="plus" > Add query from library diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx index 597b6d125c6..908126b649b 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.test.tsx @@ -96,7 +96,7 @@ describe('AccordianKeyValues test', () => { it('renders the summary instead of the table when it is not expanded', () => { setupAccordian({ isOpen: false } as AccordianKeyValuesProps); - expect(screen.getByRole('switch', { name: 'test accordian: span.kind client omg mos-def' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'test accordian span.kind client omg mos-def' })).toBeInTheDocument(); expect(screen.queryByRole('table')).not.toBeInTheDocument(); expect(screen.queryAllByRole('cell')).toHaveLength(0); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx index d8d621cf218..93822458ac7 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -17,7 +17,7 @@ import cx from 'classnames'; import * as React from 'react'; import { GrafanaTheme2, TraceKeyValuePair } from '@grafana/data'; -import { Icon, useStyles2 } from '@grafana/ui'; +import { Counter, Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import TNil from '../../types/TNil'; @@ -43,6 +43,10 @@ export const getStyles = (theme: GrafanaTheme2) => { background: autoColor(theme, '#e8e8e8'), }, }), + headerLabel: css({ + width: '120px', + display: 'inline-block', + }), headerEmpty: css({ label: 'headerEmpty', background: 'none', @@ -87,6 +91,9 @@ export type AccordianKeyValuesProps = { logName?: string; highContrast?: boolean; interactive?: boolean; + onlyValues?: boolean; + showSummary?: boolean; + showCountBadge?: boolean; isOpen: boolean; label: string | React.ReactNode; linksGetter?: ((pairs: TraceKeyValuePair[], index: number) => KeyValuesTableLink[]) | TNil; @@ -127,6 +134,9 @@ export default function AccordianKeyValues({ isOpen, label, linksGetter, + onlyValues = false, + showSummary = true, + showCountBadge = false, onToggle = null, }: AccordianKeyValuesProps) { const isEmpty = (!Array.isArray(data) || !data.length) && !logName; @@ -148,7 +158,7 @@ export default function AccordianKeyValues({ }; } - const showDataSummaryFields = data.length > 0 && !isOpen; + const showDataSummaryFields = showSummary && data.length > 0 && !isOpen; return (
@@ -161,9 +171,9 @@ export default function AccordianKeyValues({ data-testid="AccordianKeyValues--header" > {arrow} - + {label} - {showDataSummaryFields && ':'} + {showCountBadge ? : null} {showDataSummaryFields && ( @@ -171,7 +181,7 @@ export default function AccordianKeyValues({ )}
- {isOpen && } + {isOpen && }
); } diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx index 18085f3fdcb..19ff85b2dd7 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx @@ -103,10 +103,10 @@ describe('AccordianLogs tests', () => { setup({ isOpen: true, openedItems: new Set() } as AccordianLogsProps); expect( screen.getByRole('switch', { - name: '15μs (foo event name) : message oh the next log message more stuff', + name: '15μs (foo event name) message oh the next log message more stuff', }) ).toBeInTheDocument(); - expect(screen.getByRole('switch', { name: '5μs: message oh the log message something else' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: '5μs message oh the log message something else' })).toBeInTheDocument(); }); it('renders event name and duration when events list is open', () => { diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index 5584760b04b..b708b7f3ec8 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -32,14 +32,12 @@ const getStyles = (theme: GrafanaTheme2) => { AccordianLogs: css({ label: 'AccordianLogs', position: 'relative', - marginBottom: '0.25rem', }), AccordianLogsHeader: css({ label: 'AccordianLogsHeader', color: 'inherit', display: 'flex', alignItems: 'center', - padding: '0.25rem 0.1em', '&:hover': { background: autoColor(theme, '#e8e8e8'), }, diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx index d7296055bfc..14fa5187396 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.test.tsx @@ -75,7 +75,7 @@ describe('AccordianReferences tests', () => { it('renders the correct number of references', () => { setup(); - expect(screen.getByRole('switch', { name: 'References (3)' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'References 3' })).toBeInTheDocument(); }); it('content doesnt show when not expanded', () => { @@ -88,7 +88,7 @@ describe('AccordianReferences tests', () => { it('renders the content when it is expanded', () => { setup({ isOpen: true } as AccordianReferencesProps); - expect(screen.getByRole('switch', { name: 'References (3)' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'References 3' })).toBeInTheDocument(); expect(screen.getAllByRole('link', { name: /^service\d\sop\d/ })).toHaveLength(2); expect(screen.getByRole('link', { name: /^View\sLinked/ })).toBeInTheDocument(); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index de60443707e..10c31897ec6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import { Field, GrafanaTheme2, LinkModel } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Icon, useStyles2 } from '@grafana/ui'; +import { Counter, Icon, useStyles2 } from '@grafana/ui'; import { autoColor } from '../../Theme'; import { TraceSpanReference } from '../../types/trace'; @@ -36,16 +36,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), AccordianReferences: css({ label: 'AccordianReferences', - border: `1px solid ${autoColor(theme, '#d8d8d8')}`, position: 'relative', marginBottom: '0.25rem', }), AccordianReferencesHeader: css({ label: 'AccordianReferencesHeader', - background: autoColor(theme, '#e4e4e4'), color: 'inherit', display: 'block', - padding: '0.25rem 0.5rem', + padding: '0.25rem 0', '&:hover': { background: autoColor(theme, '#dadada'), }, @@ -223,7 +221,7 @@ const AccordianReferences = ({ References {' '} - ({data.length}) + {isOpen && ( ', () => { - const props = { - compact: false, - data: warnings, - highContrast: false, - isOpen: false, - label: 'le-label', - onToggle: jest.fn(), - }; - - it('renders without exploding', () => { - render(); - expect(() => render()).not.toThrow(); - }); - - it('renders the label', () => { - render(); - const { getByText } = within(screen.getByTestId('AccordianText--header')); - expect(getByText(props.label)).toBeInTheDocument(); - }); - - it('renders the content when it is expanded', () => { - props.isOpen = true; - render(); - warnings.forEach((warning) => { - expect(screen.getByText(warning)).toBeInTheDocument(); - }); - }); -}); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx deleted file mode 100644 index f0efc64b10a..00000000000 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2019 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { css } from '@emotion/css'; -import cx from 'classnames'; -import * as React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, useStyles2 } from '@grafana/ui'; - -import { autoColor } from '../../Theme'; -import TNil from '../../types/TNil'; - -import { getStyles as getAccordianKeyValuesStyles } from './AccordianKeyValues'; -import TextList from './TextList'; - -import { alignIcon } from '.'; - -const getStyles = (theme: GrafanaTheme2) => ({ - header: css({ - cursor: 'pointer', - overflow: 'hidden', - padding: '0.25em 0.1em', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - '&:hover': { - background: autoColor(theme, '#e8e8e8'), - }, - }), -}); - -type AccordianTextProps = { - className?: string | TNil; - headerClassName?: string | TNil; - data: string[]; - highContrast?: boolean; - interactive?: boolean; - isOpen: boolean; - label: React.ReactNode | string; - onToggle?: null | (() => void); - TextComponent?: React.ElementType<{ data: string[] }>; -}; - -function DefaultTextComponent({ data }: { data: string[] }) { - return ; -} - -export default function AccordianText({ - className = null, - data, - headerClassName, - highContrast = false, - interactive = true, - isOpen, - label, - onToggle = null, - TextComponent = DefaultTextComponent, -}: AccordianTextProps) { - const isEmpty = !Array.isArray(data) || !data.length; - const accordianKeyValuesStyles = useStyles2(getAccordianKeyValuesStyles); - const iconCls = cx(alignIcon, { [accordianKeyValuesStyles.emptyIcon]: isEmpty }); - let arrow: React.ReactNode | null = null; - let headerProps: {} | null = null; - if (interactive) { - arrow = isOpen ? ( - - ) : ( - - ); - headerProps = { - 'aria-checked': isOpen, - onClick: isEmpty ? null : onToggle, - role: 'switch', - }; - } - const styles = useStyles2(getStyles); - return ( -
-
- {arrow} - {label} ({data.length}) -
- {isOpen && } -
- ); -} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx index 83632c9351d..a6c79b2d2cf 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -47,7 +47,7 @@ export const getStyles = (theme: GrafanaTheme2) => { row: css({ label: 'row', '& > td': { - padding: '0.5rem 0.5rem', + padding: '0 0.5rem', height: '30px', }, '&:nth-child(2n) > td': { @@ -68,7 +68,6 @@ export const getStyles = (theme: GrafanaTheme2) => { color: autoColor(theme, '#888'), whiteSpace: 'pre', width: '125px', - verticalAlign: 'top', }), copyColumn: css({ label: 'copyColumn', @@ -118,19 +117,32 @@ export const LinkValue = ({ link, children }: PropsWithChildren) export type KeyValuesTableProps = { data: TraceKeyValuePair[]; linksGetter?: ((pairs: TraceKeyValuePair[], index: number) => KeyValuesTableLink[]) | TNil; + onlyValues?: boolean; }; export default function KeyValuesTable(props: KeyValuesTableProps) { - const { data, linksGetter } = props; + const { data, linksGetter, onlyValues } = props; const styles = useStyles2(getStyles); return (
{data.map((row, i) => { - const markup = { - __html: jsonMarkup(parseIfComplexJson(row.value)), - }; + let markup = { __html: '' }; + if (row.type === 'code') { + markup = { + __html: `
${row.value}
`, + }; + } else if (row.type === 'text') { + markup = { + __html: `${row.value}`, + }; + } else { + markup = { + __html: jsonMarkup(parseIfComplexJson(row.value)), + }; + } + const jsonTable =
; const links = linksGetter?.(data, i); let valueMarkup; @@ -147,15 +159,17 @@ export default function KeyValuesTable(props: KeyValuesTableProps) { return ( // `i` is necessary in the key because row.key can repeat
- + {!onlyValues && ( + + )} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx index f59b1fb4421..29ee78fb866 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx @@ -15,9 +15,10 @@ import { css } from '@emotion/css'; import cx from 'classnames'; +import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -const getStyles = () => ({ +const getStyles = (theme: GrafanaTheme2) => ({ TextList: css({ maxHeight: '450px', overflow: 'auto', @@ -32,7 +33,7 @@ const getStyles = () => ({ padding: '0.25rem 0.5rem', verticalAlign: 'top', '&:nth-child(2n)': { - background: '#f5f5f5', + background: theme.colors.background.secondary, }, }), }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 67a6043f397..627e8fc2ec4 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -29,11 +29,11 @@ import { PluginExtensionPoints, IconName, } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; +import { t } from '@grafana/i18n'; import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend'; import { usePluginLinks } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; -import { TextArea, useStyles2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import { pyroscopeProfileIdTagKey } from '../../../createSpanLink'; import { autoColor } from '../../Theme'; @@ -46,7 +46,6 @@ import { formatDuration } from '../utils'; import AccordianKeyValues from './AccordianKeyValues'; import AccordianLogs from './AccordianLogs'; import AccordianReferences from './AccordianReferences'; -import AccordianText from './AccordianText'; import DetailState from './DetailState'; import { ShareSpanButton } from './ShareSpanButton'; import { getSpanDetailLinkButtons } from './SpanDetailLinkButtons'; @@ -99,6 +98,9 @@ const getStyles = (theme: GrafanaTheme2) => { gap: '0 1rem', marginBottom: '0.25rem', }), + content: css({ + fontSize: theme.typography.bodySmall.fontSize, + }), listWrapper: css({ overflow: 'hidden', flexGrow: 1, @@ -340,7 +342,7 @@ export default function SpanDetail(props: SpanDetailProps) {
{linksComponent}
-
+
)} + {warnings && warnings.length > 0 && ( - - Warnings - - } - data={warnings} + ({ + key: '', + value: warning, + type: 'text', + }))} + showSummary={false} + showCountBadge={true} isOpen={isWarningsOpen} + onlyValues={true} onToggle={() => warningsToggle(spanID)} + label={t('explore.span-detail.warnings', 'Warnings')} /> )} + {stackTraces?.length ? ( - ({ + key: '', + value: stackTrace, + type: 'code', + }))} + onlyValues={true} + showSummary={false} + showCountBadge={true} isOpen={isStackTracesOpen} - TextComponent={(textComponentProps) => { - let text; - if (textComponentProps.data?.length > 1) { - text = textComponentProps.data - .map((stackTrace, index) => `StackTrace ${index + 1}:\n${stackTrace}`) - .join('\n'); - } else { - text = textComponentProps.data?.[0]; - } - return ( -
- {row.key} - + {row.key} + {valueMarkup}